From b324843a99a15492e991cdd37fddb78fdc53d72b Mon Sep 17 00:00:00 2001 From: Christian Gambale Date: Fri, 23 Oct 2026 02:13:44 +0200 Subject: [PATCH 1/4] Add relation to relatedAnime relatedAnime entries are now objects: {uri, relation}. relation is the metadata provider's own label and is omitted if the provider doesn't name it. The old string format can still be read. --- .../modb/anidb/AnidbAnimeConverter.kt | 20 +- .../modb/anidb/AnidbApiAnimeConverter.kt | 13 +- .../modb/anidb/AnidbAnimeConverterTest.kt | 3581 +-- .../modb/anidb/AnidbApiAnimeConverterTest.kt | 3 +- .../modb/anilist/AnilistAnimeConverter.kt | 434 +- .../modb/anilist/AnilistAnimeConverterTest.kt | 2173 +- .../animeplanet/AnimePlanetAnimeConverter.kt | 637 +- .../AnimePlanetAnimeConverterTest.kt | 2521 +-- .../AnimenewsnetworkAnimeConverter.kt | 914 +- .../AnimenewsnetworkAnimeConverterTest.kt | 3845 ++-- .../modb/anisearch/AnisearchAnimeConverter.kt | 651 +- .../anisearch/AnisearchAnimeConverterTest.kt | 4047 ++-- .../manamiproject/modb/core/anime/Anime.kt | 4 +- .../manamiproject/modb/core/anime/AnimeRaw.kt | 1376 +- .../modb/core/anime/RelatedAnime.kt | 47 + .../modb/core/extractor/Descendant.kt | 247 +- .../JsoupCssSelectorDataExtractor.kt | 1 + .../core/extractor/JsoupXPathDataExtractor.kt | 132 +- .../modb/core/extractor/LabelledLinks.kt | 57 + .../modb/core/json/AnimeAdapter.kt | 437 +- .../modb/core/json/AnimeRawAdapter.kt | 441 +- .../manamiproject/modb/core/json/Json.kt | 235 +- .../modb/core/json/RelatedAnimeAdapter.kt | 56 + .../manamiproject/modb/core/TestingAssets.kt | 2504 ++- .../modb/core/anime/AnimeRawTest.kt | 18775 ++++++++-------- .../modb/core/anime/RelatedAnimeTest.kt | 98 + .../modb/core/extractor/DescendantTest.kt | 1016 +- .../modb/core/extractor/LabelledLinksTest.kt | 133 + .../modb/core/json/AnimeRawAdapterTest.kt | 3597 +-- .../modb/core/json/JsonKtTest.kt | 878 +- .../modb/core/json/RelatedAnimeAdapterTest.kt | 155 + .../modb/kitsu/KitsuAnimeConverter.kt | 494 +- .../modb/kitsu/KitsuAnimeConverterTest.kt | 2667 +-- .../app/dataset/AnimeCountdownUrlAdder.kt | 44 +- .../modb/app/readme/DefaultReadmeCreator.kt | 1068 +- .../DefaultAmbiguousSynonymRemover.kt | 2 +- .../manamiproject/modb/app/TestingAssets.kt | 2565 +-- .../app/dataset/AnimeCountdownUrlAdderTest.kt | 222 +- ...efaultDownloadControlStateSchedulerTest.kt | 645 +- .../DefaultDownloadControlStateUpdaterTest.kt | 1884 +- .../DownloadControlStateEntryTest.kt | 2276 +- .../app/merging/DefaultMergingServiceTest.kt | 1081 +- .../DefaultGoldenRecordAccessorTest.kt | 2211 +- ...efaultMatchingProbabilityCalculatorTest.kt | 1654 +- .../DefaultUnknownRelatedAnimeRemoverTest.kt | 183 +- .../DefaultAmbiguousSynonymRemoverTest.kt | 35 +- .../animecountdown-with-zero-entries.md | 756 +- .../expected-default.md | 770 +- .../DefaultReadmeCreatorTest/merge-locks.md | 770 +- .../reviewed-isolated-entries.md | 770 +- .../zero-based-week.md | 770 +- .../modb/livechart/LivechartAnimeConverter.kt | 673 +- .../livechart/LivechartAnimeConverterTest.kt | 2809 +-- .../myanimelist/MyanimelistAnimeConverter.kt | 634 +- .../MyanimelistAnimeConverterTest.kt | 3911 ++-- .../manamiproject/modb/serde/TestingAssets.kt | 2192 +- .../modb/simkl/SimklAnimeConverter.kt | 698 +- .../modb/simkl/SimklAnimeConverterTest.kt | 2354 +- 58 files changed, 42195 insertions(+), 40971 deletions(-) create mode 100644 core/src/main/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnime.kt create mode 100644 core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinks.kt create mode 100644 core/src/main/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapter.kt create mode 100644 core/src/test/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnimeTest.kt create mode 100644 core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinksTest.kt create mode 100644 core/src/test/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapterTest.kt diff --git a/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverter.kt b/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverter.kt index 89ec3935..f814dbea 100644 --- a/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverter.kt +++ b/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverter.kt @@ -9,6 +9,7 @@ import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank import io.github.manamiproject.modb.core.extensions.remove import io.github.manamiproject.modb.core.extractor.DataExtractor import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks import io.github.manamiproject.modb.core.extractor.XmlDataExtractor import io.github.manamiproject.modb.core.anime.* import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE @@ -53,7 +54,7 @@ public class AnidbAnimeConverter( "season" to "//tr[contains(@class, 'season')]//td[contains(@class, 'value')]/text()", "startDate" to "//tr[contains(@class, 'year')]//td[contains(@class, 'value')]//span[contains(@itemprop, 'startDate')]/@content", "datePublished" to "//tr[contains(@class, 'year')]//td[contains(@class, 'value')]//span[contains(@itemprop, 'datePublished')]/@content", - "relatedAnime" to "//div[contains(@class, 'directly_related')]//a/@href", + "relatedAnime" to "//div[contains(@class, 'directly_related')]//div[contains(@class, 'stripe')]/html()", "alternateNames" to "//label[contains(@itemprop, 'alternateName')]/text()", "synonymsList" to "//div[contains(@class, 'titles')]//tr[contains(@class, 'syn')]/td/text()", "shortNames" to "//div[contains(@class, 'titles')]//tr[contains(@class, 'short')]/td/text()", @@ -171,17 +172,20 @@ public class AnidbAnimeConverter( return hashSetOf(metaDataProviderConfig.buildAnimeLink(id)) } - private fun extractRelatedAnime(data: ExtractionResult): HashSet { + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet { if (data.notFound("relatedAnime")) { return hashSetOf() } - return data.listNotNull("relatedAnime") - .asSequence() - .filterNot { it.contains("relation") } - .map { it.remove("/anime/") } - .distinct() - .map { metaDataProviderConfig.buildAnimeLink(it) } + //
... ...
Sequel
+ // Entries in "indirectly related" don't have a type. + return extractor.extractLabelledLinks( + snippets = data.listNotNull("relatedAnime"), + labelSelector = "//div[@class='type']/text()", + linkSelector = "//a/@href", + ).flatMap { entry -> entry.links.map { it to entry.label } } + .filterNot { (link, _) -> link.contains("relation") } + .map { (link, label) -> RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(link.remove("/anime/")), label) } .toHashSet() } diff --git a/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverter.kt b/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverter.kt index 73771c90..0d1f697d 100644 --- a/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverter.kt +++ b/anidb/src/main/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverter.kt @@ -17,6 +17,7 @@ import io.github.manamiproject.modb.core.extensions.EMPTY import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank import io.github.manamiproject.modb.core.extractor.DataExtractor import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks import io.github.manamiproject.modb.core.extractor.XmlDataExtractor import kotlinx.coroutines.withContext import java.net.URI @@ -50,7 +51,7 @@ public class AnidbApiAnimeConverter( "mainTitle" to "//anime/titles/title[@type='main']/text()", "otherTitles" to "//anime/titles/title[@type!='main']/text()", "tags" to "//anime/tags/tag/name/text()", - "relatedAnime" to "//anime/relatedanime/anime/@id", + "relatedAnime" to "//anime/relatedanime/anime/html()", "episodeLengths" to "//anime/episodes/episode/length/text()", "score" to "//anime/ratings/permanent/text()", )) @@ -169,13 +170,17 @@ public class AnidbApiAnimeConverter( .toHashSet() } - private fun extractRelatedAnime(data: ExtractionResult): HashSet { + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet { if (data.notFound("relatedAnime")) { return hashSetOf() } - return data.listNotNull("relatedAnime") - .map { metaDataProviderConfig.buildAnimeLink(it.trim()) } + // Cowboy Bebop: Tengoku no Tobira + return extractor.extractLabelledLinks( + snippets = data.listNotNull("relatedAnime"), + labelSelector = "//anime/@type", + linkSelector = "//anime/@id", + ).flatMap { entry -> entry.links.map { RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(it.trim()), entry.label) } } .toHashSet() } diff --git a/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt b/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt index 8098276b..cfaaaaa5 100644 --- a/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt +++ b/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt @@ -1,1790 +1,1791 @@ -package io.github.manamiproject.modb.anidb - -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class AnidbAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/title/special_chars.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo(".hack//G.U. Trilogy") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `1 episode, although more entries are listed - the selector returns null`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/1_but_more_entries.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `1 episode - the selector returns null`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/1.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/10.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/100.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/1818.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is Movie`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/movie.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type 'Music Video' is mapped to 'Special'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/music_video.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type 'Other' is mapped to 'Special'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/other.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/ova.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type 'TV Series' is mapped to 'TV'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/tv_series.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type 'TV Special' is mapped to 'Special'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/tv_special.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type 'Unknown' is mapped to 'UNKNOWN'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `type 'Web' is mapped to 'ONA'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/type/web.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg-thumb.jpg")) - } - } - - @Test - fun `eu cdn is replaced by default cdn for both picture and thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/eu_cdn_replaced_by_default_cdn.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) - } - } - - @Test - fun `us cdn is replaced by default cdn for both picture and thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/us_cdn_replaced_by_default_cdn.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 11221`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/sources/11221.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://anidb.net/anime/11221")) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no relations`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/related_anime/no_related_anime.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `multiple relations`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/related_anime/multiple_related_anime.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://anidb.net/anime/405"), - URI("https://anidb.net/anime/368"), - URI("https://anidb.net/anime/2850"), - URI("https://anidb.net/anime/4576"), - URI("https://anidb.net/anime/2995"), - URI("https://anidb.net/anime/6141"), - URI("https://anidb.net/anime/367"), - URI("https://anidb.net/anime/2996"), - URI("https://anidb.net/anime/6393"), - ) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `gather all possible synonyms from info and titles tab`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/synonyms/all_types.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Bilježnica smrti", - "Caderno da Morte", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "מחברת המוות", - "دفتر الموت", - "دفترچه مرگ", - "دفترچه یادداشت مرگ", - "ديث نوت", - "كـتـاب الـموت", - "مدونة الموت", - "مذكرة المـوت", - "مذكرة الموت", - "موت نوٹ", - "डेथ नोट", - "デスノート", - "死亡笔记", - "데스노트", - ) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `FINISHED by date published`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/date_published.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `ONGOING by date published`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2013-06-13T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/date_published.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `UPCOMING by date published`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2012-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/date_published.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `FINISHED by start to end`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_end.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `ONGOING by start to end`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2006-05-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_end.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `UPCOMING by start to end`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2005-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_end.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `ONGOING by start to unknown`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2023-10-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_unknown.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `UPCOMING by start to unknown`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2022-10-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_unknown.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `Neither time period nor date published is mapped to UNKNOWN`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/status/unknown.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple ignorig the link to find similar anime`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/tags/multiple_tags_with_similar.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "contemporary fantasy", - "detective", - "manga", - "shounen", - "thriller", - ) - } - } - - @Test - fun `empty list of no tags are available`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/tags/no_tags.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - - @Test - fun `extract multiple titles if the link to search for similar anime is not present`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/tags/multiple_tags_without_similar.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly( - "new", - "short movie", - ) - } - } - - @Test - fun `extract exactly one tag`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/tags/one_tag.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly( - "new", - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `null results in 0 seconds`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/duration/missing.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `0 minutes - anidb does not provide seconds`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/duration/0_minutes.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, MINUTES)) - } - } - - @Test - fun `1 minute`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/duration/1_minute.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `25 minutes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/duration/25_minutes.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(25, MINUTES)) - } - } - - @Test - fun `1 hour`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/duration/1_hour.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `2 hours`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/duration/2_hours.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `2017-10-03 - unknown`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-03_-_unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2017) - } - } - - @Test - fun `2019-07-07 - 2019-09-22`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-07-07_-_2019-09-22.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2019) - } - } - - @Test - fun `2019-08-23`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-08-23.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2019) - } - } - - @Test - fun `2017-10-14 - 2020`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-14_-_2020.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2017) - } - } - - @Test - fun `2019-10-05 - 2020-03`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-10-05_-_2020-03.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2019) - } - } - - @Test - fun `2004`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2004.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2004) - } - } - - @Test - fun `1986-06`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/1986-06.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1986) - } - } - - @Test - fun `2020 - unknown`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2020_-_unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2020) - } - } - - @Test - fun `not available`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/not_available.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(0) - } - } - - @Test - fun `unknown date with year - unknown`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/unknown_date_of_year_-_unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2021) - } - } - - @Test - fun `time period but using date published`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/date_published_but_with_time_period.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2014) - } - } - } - - @Nested - inner class SeasonTests { - - @Nested - inner class SeasonCellTests { - - @Test - fun `season is 'spring'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_spring.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'summer'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_summer.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'fall'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_autumn.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'winter'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_winter.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class DatePublishedCellTests { - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_apr", "date_published_cell_may"]) - fun `season is 'spring'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_jul", "date_published_cell_aug", "date_published_cell_sep"]) - fun `season is 'summer'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_oct", "date_published_cell_nov", "date_published_cell_dec"]) - fun `season is 'fall'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_jan", "date_published_cell_feb", "date_published_cell_mar"]) - fun `season is 'winter'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class StartDateCellTests { - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_apr", "start_date_cell_may", "start_date_cell_jun"]) - fun `season is 'spring'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_jul", "start_date_cell_aug", "start_date_cell_sep"]) - fun `season is 'summer'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_oct", "start_date_cell_nov", "start_date_cell_dec"]) - fun `season is 'fall'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_jan", "start_date_cell_feb"]) - fun `season is 'winter'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Test - fun `neither startDate nor datePublished exist therefore season is 'undefined'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/season/undefined.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully extracts score`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val converter = AnidbAnimeConverter(testAnidbConfig) - - val testFile = loadTestResource("AnidbAnimeConverterTest/scores/score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.65) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val converter = AnidbAnimeConverter(testAnidbConfig) - - val testFile = loadTestResource("AnidbAnimeConverterTest/scores/no-score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `always empty, because they mix up persons and actual studios`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/sources/11221.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `always empty, because they mix up persons and actual studios and list them under different headings`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource("AnidbAnimeConverterTest/sources/11221.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = AnidbAnimeConverter.instance - - // when - val result = AnidbAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnidbAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anidb + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class AnidbAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/title/special_chars.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo(".hack//G.U. Trilogy") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `1 episode, although more entries are listed - the selector returns null`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/1_but_more_entries.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `1 episode - the selector returns null`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/1.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/10.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/100.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/1818.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/episodes/unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is Movie`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/movie.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type 'Music Video' is mapped to 'Special'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/music_video.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type 'Other' is mapped to 'Special'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/other.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/ova.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type 'TV Series' is mapped to 'TV'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/tv_series.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type 'TV Special' is mapped to 'Special'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/tv_special.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type 'Unknown' is mapped to 'UNKNOWN'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `type 'Web' is mapped to 'ONA'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/type/web.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg-thumb.jpg")) + } + } + + @Test + fun `eu cdn is replaced by default cdn for both picture and thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/eu_cdn_replaced_by_default_cdn.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) + } + } + + @Test + fun `us cdn is replaced by default cdn for both picture and thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/picture_and_thumbnail/us_cdn_replaced_by_default_cdn.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 11221`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/sources/11221.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://anidb.net/anime/11221")) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no relations`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/related_anime/no_related_anime.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `multiple relations`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/related_anime/multiple_related_anime.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anidb.net/anime/405"), "Sequel"), + RelatedAnime(URI("https://anidb.net/anime/368"), "Side story"), + RelatedAnime(URI("https://anidb.net/anime/2850")), + RelatedAnime(URI("https://anidb.net/anime/4576"), "Prequel"), + RelatedAnime(URI("https://anidb.net/anime/2995")), + RelatedAnime(URI("https://anidb.net/anime/6141"), "Side story"), + RelatedAnime(URI("https://anidb.net/anime/367"), "Side story"), + RelatedAnime(URI("https://anidb.net/anime/2996")), + RelatedAnime(URI("https://anidb.net/anime/6393"), "Side story"), + ) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `gather all possible synonyms from info and titles tab`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/synonyms/all_types.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Bilježnica smrti", + "Caderno da Morte", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "מחברת המוות", + "دفتر الموت", + "دفترچه مرگ", + "دفترچه یادداشت مرگ", + "ديث نوت", + "كـتـاب الـموت", + "مدونة الموت", + "مذكرة المـوت", + "مذكرة الموت", + "موت نوٹ", + "डेथ नोट", + "デスノート", + "死亡笔记", + "데스노트", + ) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `FINISHED by date published`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/date_published.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `ONGOING by date published`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2013-06-13T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/date_published.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `UPCOMING by date published`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2012-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/date_published.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `FINISHED by start to end`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_end.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `ONGOING by start to end`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2006-05-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_end.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `UPCOMING by start to end`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2005-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_end.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `ONGOING by start to unknown`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2023-10-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_unknown.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `UPCOMING by start to unknown`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2022-10-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/start_to_unknown.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `Neither time period nor date published is mapped to UNKNOWN`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/status/unknown.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple ignorig the link to find similar anime`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/tags/multiple_tags_with_similar.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "contemporary fantasy", + "detective", + "manga", + "shounen", + "thriller", + ) + } + } + + @Test + fun `empty list of no tags are available`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/tags/no_tags.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + + @Test + fun `extract multiple titles if the link to search for similar anime is not present`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/tags/multiple_tags_without_similar.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly( + "new", + "short movie", + ) + } + } + + @Test + fun `extract exactly one tag`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/tags/one_tag.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly( + "new", + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `null results in 0 seconds`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/duration/missing.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `0 minutes - anidb does not provide seconds`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/duration/0_minutes.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, MINUTES)) + } + } + + @Test + fun `1 minute`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/duration/1_minute.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `25 minutes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/duration/25_minutes.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(25, MINUTES)) + } + } + + @Test + fun `1 hour`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/duration/1_hour.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `2 hours`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/duration/2_hours.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `2017-10-03 - unknown`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-03_-_unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2017) + } + } + + @Test + fun `2019-07-07 - 2019-09-22`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-07-07_-_2019-09-22.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2019) + } + } + + @Test + fun `2019-08-23`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-08-23.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2019) + } + } + + @Test + fun `2017-10-14 - 2020`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-14_-_2020.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2017) + } + } + + @Test + fun `2019-10-05 - 2020-03`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-10-05_-_2020-03.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2019) + } + } + + @Test + fun `2004`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2004.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2004) + } + } + + @Test + fun `1986-06`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/1986-06.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1986) + } + } + + @Test + fun `2020 - unknown`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/2020_-_unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2020) + } + } + + @Test + fun `not available`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/not_available.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(0) + } + } + + @Test + fun `unknown date with year - unknown`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/unknown_date_of_year_-_unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2021) + } + } + + @Test + fun `time period but using date published`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/year_of_premiere/date_published_but_with_time_period.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2014) + } + } + } + + @Nested + inner class SeasonTests { + + @Nested + inner class SeasonCellTests { + + @Test + fun `season is 'spring'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_spring.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'summer'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_summer.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'fall'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_autumn.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'winter'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/season_cell_winter.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class DatePublishedCellTests { + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_apr", "date_published_cell_may"]) + fun `season is 'spring'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_jul", "date_published_cell_aug", "date_published_cell_sep"]) + fun `season is 'summer'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_oct", "date_published_cell_nov", "date_published_cell_dec"]) + fun `season is 'fall'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_jan", "date_published_cell_feb", "date_published_cell_mar"]) + fun `season is 'winter'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class StartDateCellTests { + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_apr", "start_date_cell_may", "start_date_cell_jun"]) + fun `season is 'spring'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_jul", "start_date_cell_aug", "start_date_cell_sep"]) + fun `season is 'summer'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_oct", "start_date_cell_nov", "start_date_cell_dec"]) + fun `season is 'fall'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_jan", "start_date_cell_feb"]) + fun `season is 'winter'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Test + fun `neither startDate nor datePublished exist therefore season is 'undefined'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/anime_season/season/undefined.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully extracts score`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val converter = AnidbAnimeConverter(testAnidbConfig) + + val testFile = loadTestResource("AnidbAnimeConverterTest/scores/score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.65) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val converter = AnidbAnimeConverter(testAnidbConfig) + + val testFile = loadTestResource("AnidbAnimeConverterTest/scores/no-score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `always empty, because they mix up persons and actual studios`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/sources/11221.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `always empty, because they mix up persons and actual studios and list them under different headings`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource("AnidbAnimeConverterTest/sources/11221.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = AnidbAnimeConverter.instance + + // when + val result = AnidbAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnidbAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverterTest.kt b/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverterTest.kt index ec90717d..976da2e7 100644 --- a/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverterTest.kt +++ b/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbApiAnimeConverterTest.kt @@ -1,5 +1,6 @@ package io.github.manamiproject.modb.anidb +import io.github.manamiproject.modb.core.anime.RelatedAnime import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SPRING import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED import io.github.manamiproject.modb.core.anime.AnimeType.TV @@ -43,7 +44,7 @@ internal class AnidbApiAnimeConverterTest { assertThat(result.sources).containsExactly(URI("https://anidb.net/anime/23")) assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/221595.jpg")) assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/221595.jpg-thumb.jpg")) - assertThat(result.relatedAnime).containsExactly(URI("https://anidb.net/anime/219")) + assertThat(result.relatedAnime).containsExactly(RelatedAnime(URI("https://anidb.net/anime/219"), "Side Story")) assertThat(result.synonyms).contains("Kaubojus Bibopas") assertThat(result.tags).contains("bounty hunter", "detective") } diff --git a/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt b/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt index eb421a62..2a521f41 100644 --- a/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt +++ b/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt @@ -1,214 +1,220 @@ -package io.github.manamiproject.modb.anilist - -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import kotlinx.coroutines.withContext -import java.net.URI - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extractor which retrieves the data from raw data. - */ -public class AnilistAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnilistConfig, - private val extractor: DataExtractor = JsonDataExtractor, -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - - val data: ExtractionResult = extractor.extract(rawContent, mapOf( - "userPreferred" to "$.data.Media.title.userPreferred", - "titles" to "$.data.Media.title", - "episodes" to "$.data.Media.episodes", - "nextAiringEpisode" to "$.data.Media.nextAiringEpisode.episode", - "format" to "$.data.Media.format", - "picture" to "$.data.Media.coverImage.large", - "synonyms" to "$.data.Media.synonyms", - "duration" to "$.data.Media.duration", - "season" to "$.data.Media.season", - "year" to "$.data.Media.seasonYear", - "startYear" to "$.data.Media.startDate.year", - "genres" to "$.data.Media.genres", - "id" to "$.data.Media.id", - "status" to "$.data.Media.status", - "tags" to "$.data.Media.tags.*.name", - "relatedAnime" to "$.data.Media.relations.edges.*.node", - "score" to "$.data.Media.meanScore", - "creators" to "$.data.Media.studios.edges", - )) - - return@withContext AnimeRaw( - _title = extractTitle(data), - episodes = extractEpisodes(data), - type = extractType(data), - picture = extractPicture(data), - thumbnail = extractThumbnail(data), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = extractSynonyms(data), - _tags = extractTags(data), - _relatedAnime = extractRelatedAnime(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun extractTitle(data: ExtractionResult): String = data.stringOrDefault("userPreferred") - - private fun extractEpisodes(data: ExtractionResult): Int { - if (!data.notFound("episodes")) { - return data.int("episodes") - } - - return data.intOrDefault("nextAiringEpisode") - } - - private fun extractType(data: ExtractionResult): AnimeType { - if (data.notFound("format")) { - return UNKNOWN_TYPE - } - - return when(data.string("format").trim().uppercase()) { - "TV" -> TV - "TV_SHORT" -> TV - "MOVIE" -> MOVIE - "ONA" -> ONA - "OVA" -> OVA - "SPECIAL" -> SPECIAL - "MUSIC" -> SPECIAL - else -> throw IllegalStateException("Unknown type [${data.string("format")}]") - } - } - - private fun extractPicture(data: ExtractionResult): URI { - return if (data.notFound("picture")) { - NO_PICTURE - } else { - URI(data.string("picture").trim()) - } - } - - private fun extractThumbnail(data: ExtractionResult): URI { - return if (data.notFound("picture")) { - NO_PICTURE_THUMBNAIL - } else { - URI(data.string("picture").trim()) - } - } - - private fun extractSynonyms(data: ExtractionResult): HashSet { - return data.listNotNull<LinkedHashMap<String, Title?>>("titles") - .flatMap { it.values } - .union(data.listNotNull<Title>("synonyms")) - .asSequence() - .filterNot { it == data.string("userPreferred") } - .filterNotNull() - .toHashSet() - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> { - return data.listNotNull<LinkedHashMap<String, Any>>("relatedAnime") - .filter { it["type"] == "ANIME" } - .mapNotNull { it["id"] } - .map { metaDataProviderConfig.buildAnimeLink(it.toString().trim()) } - .toHashSet() - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - if (data.notFound("status")) { - return UNKNOWN_STATUS - } - - return when(data.string("status").trim().uppercase()) { - "FINISHED" -> FINISHED - "RELEASING" -> ONGOING - "NOT_YET_RELEASED" -> UPCOMING - "CANCELLED" -> UNKNOWN_STATUS - else -> throw IllegalStateException("Unknown status [${data.string("status")}]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationInMinutes = data.intOrDefault("duration") - return Duration(durationInMinutes, MINUTES) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val seasonValue = data.stringOrDefault("season").trim() - val season = AnimeSeason.Season.of(seasonValue) - - val year = if (data.notFound("year")) { - data.intOrDefault("startYear") - } else { - data.int("year") - } - - return AnimeSeason( - season = season, - year = year, - ) - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - val genres = data.listNotNull<Tag>("genres") - val tags = data.listNotNull<Tag>("tags") - - return genres.plus(tags).toHashSet() - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = 1.0..100.0, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return data.listNotNull<Map<String, Any>>("creators") - .filter { it["isMain"] as Boolean } - .map { (it["node"] as Map<*, *>)["name"] as Studio } - .toHashSet() - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return data.listNotNull<Map<String, Any>>("creators") - .filterNot { it["isMain"] as Boolean } - .map { (it["node"] as Map<*, *>)["name"] as Producer } - .toHashSet() - } - - public companion object { - /** - * Singleton of [AnilistAnimeConverter] - * @since 6.1.0 - */ - public val instance: AnilistAnimeConverter by lazy { AnilistAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anilist + +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import kotlinx.coroutines.withContext +import java.net.URI + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extractor which retrieves the data from raw data. + */ +public class AnilistAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnilistConfig, + private val extractor: DataExtractor = JsonDataExtractor, +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + + val data: ExtractionResult = extractor.extract(rawContent, mapOf( + "userPreferred" to "$.data.Media.title.userPreferred", + "titles" to "$.data.Media.title", + "episodes" to "$.data.Media.episodes", + "nextAiringEpisode" to "$.data.Media.nextAiringEpisode.episode", + "format" to "$.data.Media.format", + "picture" to "$.data.Media.coverImage.large", + "synonyms" to "$.data.Media.synonyms", + "duration" to "$.data.Media.duration", + "season" to "$.data.Media.season", + "year" to "$.data.Media.seasonYear", + "startYear" to "$.data.Media.startDate.year", + "genres" to "$.data.Media.genres", + "id" to "$.data.Media.id", + "status" to "$.data.Media.status", + "tags" to "$.data.Media.tags.*.name", + "relatedAnime" to "$.data.Media.relations.edges", + "score" to "$.data.Media.meanScore", + "creators" to "$.data.Media.studios.edges", + )) + + return@withContext AnimeRaw( + _title = extractTitle(data), + episodes = extractEpisodes(data), + type = extractType(data), + picture = extractPicture(data), + thumbnail = extractThumbnail(data), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = extractSynonyms(data), + _tags = extractTags(data), + _relatedAnime = extractRelatedAnime(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun extractTitle(data: ExtractionResult): String = data.stringOrDefault("userPreferred") + + private fun extractEpisodes(data: ExtractionResult): Int { + if (!data.notFound("episodes")) { + return data.int("episodes") + } + + return data.intOrDefault("nextAiringEpisode") + } + + private fun extractType(data: ExtractionResult): AnimeType { + if (data.notFound("format")) { + return UNKNOWN_TYPE + } + + return when(data.string("format").trim().uppercase()) { + "TV" -> TV + "TV_SHORT" -> TV + "MOVIE" -> MOVIE + "ONA" -> ONA + "OVA" -> OVA + "SPECIAL" -> SPECIAL + "MUSIC" -> SPECIAL + else -> throw IllegalStateException("Unknown type [${data.string("format")}]") + } + } + + private fun extractPicture(data: ExtractionResult): URI { + return if (data.notFound("picture")) { + NO_PICTURE + } else { + URI(data.string("picture").trim()) + } + } + + private fun extractThumbnail(data: ExtractionResult): URI { + return if (data.notFound("picture")) { + NO_PICTURE_THUMBNAIL + } else { + URI(data.string("picture").trim()) + } + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + return data.listNotNull<LinkedHashMap<String, Title?>>("titles") + .flatMap { it.values } + .union(data.listNotNull<Title>("synonyms")) + .asSequence() + .filterNot { it == data.string("userPreferred") } + .filterNotNull() + .toHashSet() + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) + } + + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + return data.listNotNull<Map<String, Any>>("relatedAnime") + .mapNotNull { edge -> + val node = edge["node"] as? Map<*, *> ?: return@mapNotNull null + if (node["type"] != "ANIME") return@mapNotNull null + val id = node["id"] ?: return@mapNotNull null + RelatedAnime.of( + uri = metaDataProviderConfig.buildAnimeLink(id.toString().trim()), + relation = edge["relationType"]?.toString(), + ) + } + .toHashSet() + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + if (data.notFound("status")) { + return UNKNOWN_STATUS + } + + return when(data.string("status").trim().uppercase()) { + "FINISHED" -> FINISHED + "RELEASING" -> ONGOING + "NOT_YET_RELEASED" -> UPCOMING + "CANCELLED" -> UNKNOWN_STATUS + else -> throw IllegalStateException("Unknown status [${data.string("status")}]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationInMinutes = data.intOrDefault("duration") + return Duration(durationInMinutes, MINUTES) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val seasonValue = data.stringOrDefault("season").trim() + val season = AnimeSeason.Season.of(seasonValue) + + val year = if (data.notFound("year")) { + data.intOrDefault("startYear") + } else { + data.int("year") + } + + return AnimeSeason( + season = season, + year = year, + ) + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + val genres = data.listNotNull<Tag>("genres") + val tags = data.listNotNull<Tag>("tags") + + return genres.plus(tags).toHashSet() + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = 1.0..100.0, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return data.listNotNull<Map<String, Any>>("creators") + .filter { it["isMain"] as Boolean } + .map { (it["node"] as Map<*, *>)["name"] as Studio } + .toHashSet() + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return data.listNotNull<Map<String, Any>>("creators") + .filterNot { it["isMain"] as Boolean } + .map { (it["node"] as Map<*, *>)["name"] as Producer } + .toHashSet() + } + + public companion object { + /** + * Singleton of [AnilistAnimeConverter] + * @since 6.1.0 + */ + public val instance: AnilistAnimeConverter by lazy { AnilistAnimeConverter() } + } +} \ No newline at end of file diff --git a/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt b/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt index b304483a..34ba8e89 100644 --- a/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt +++ b/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt @@ -1,1086 +1,1087 @@ -package io.github.manamiproject.modb.anilist - -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class AnilistAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/title/special_chars.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is tv`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is tv_short and is mapped to tv`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv_short.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is special`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/special.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is ova`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ova.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is ona`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ona.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is movie`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/movie.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is music is mapped to special`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/music.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is null and is mapped to UNKNOWN`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `fixed number of episodes`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/39.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(39) - } - } - - @Test - fun `neither episodes nor nextairingepisode is set`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/neither_episodes_nor_nextairingepisode_is_set.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(0) - } - } - - @Test - fun `ongoing series for which the value has to be taken from nextairingepisode`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/ongoing.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(1082) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `picture is available, but anilist never provides a thumbnail so the thumbnail is the same as the picture`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_available.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) - } - } - - @Test - fun `picture is unavailable`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_unavailable.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `synonyms taken from titles and synonyms, ignoring null`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/synonyms/synonyms_from_titles_and_synonyms.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Prism Paradise", - "Tobidasu PriPara: Minna de Mezase! Idol\u2606Grand Prix", - "Tobidasu PuriPara: Mi~nna de Mezase! Idol\u2606Grand Prix", - "とびだすプリパラ み~んなでめざせ!アイドル☆グランプリ", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract correct id and build anime link correctly`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/sources/15689.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.sources.first()).isEqualTo(URI("https://anilist.co/anime/15689")) - } - } - } - - @Nested - inner class RelationsTests { - - @Test - fun `no adaption, no relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_no_relations.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no adaption, multiple relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://anilist.co/anime/107298"), - URI("https://anilist.co/anime/116147"), - URI("https://anilist.co/anime/97857"), - ) - } - } - - @Test - fun `one adaption, one relation`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactly( - URI("https://anilist.co/anime/2337") - ) - } - } - - @Test - fun `has adaption, multiple relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://anilist.co/anime/100148"), - URI("https://anilist.co/anime/1081"), - URI("https://anilist.co/anime/1301"), - URI("https://anilist.co/anime/1302"), - URI("https://anilist.co/anime/1491"), - URI("https://anilist.co/anime/1645"), - URI("https://anilist.co/anime/1676"), - URI("https://anilist.co/anime/1706"), - URI("https://anilist.co/anime/17269"), - URI("https://anilist.co/anime/2202"), - URI("https://anilist.co/anime/2203"), - URI("https://anilist.co/anime/2470"), - ) - } - } - - @Test - fun `has adaption, no relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'FINISHED' is mapped to 'FINISHED'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/finished.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `'RELEASING' is mapped to 'ONGOING'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/releasing.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'NOT_YET_RELEASED' is mapped to 'UPCOMING'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/not_yet_released.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'CANCELLED' is mapped to 'UNKNOWN'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/cancelled.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `null is mapped to 'UNKNOWN'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `put names of genres and tags as distinct list into the anime's tags`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/tags/tags.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "adventure", - "amnesia", - "anti-hero", - "crime", - "cult", - "cyberpunk", - "cyborg", - "drama", - "drugs", - "ensemble cast", - "episodic", - "gambling", - "gender bending", - "guns", - "male protagonist", - "martial arts", - "noir", - "philosophy", - "police", - "primarily adult cast", - "sci-fi", - "space", - "tanned skin", - "terrorism", - "tomboy", - "tragedy", - "travel", - "yakuza", - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `duration is not set and therefore 0`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `duration is set to 0`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/0.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `anilist only uses minutes for duration - so this entry although 15 seconds long is set to 1 min`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/min_duration.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `duration of 24 minutes`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/24.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) - } - } - - @Test - fun `duration of 2 hours`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/120.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @Test - fun `season is 'undefined'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'spring'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/spring.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'summer'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/summer.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'fall'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/fall.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'wimter'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/winter.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `seasonYear is set`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/seasonyear_set.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `year is not set and default is 0`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(0) - } - } - - @Test - fun `year is 2006 - seasonYear is null but start date is set`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_2006.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(2006) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - // when - val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/score.json")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(9.090909090909092) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - // when - val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/no-score.json")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/multiple_studios.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/no_studios.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testAnilistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = - loadTestResource<String>("AnilistAnimeConverterTest/producers/multiple_producers.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "mixer", - "north stars pictures", - "jy animation", - "crunchyroll", - "ca-cygames anime fund", - "movic", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/producers/no_producers.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = AnilistAnimeConverter.instance - - // when - val result = AnilistAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnilistAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anilist + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class AnilistAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/title/special_chars.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is tv`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is tv_short and is mapped to tv`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv_short.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is special`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/special.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is ova`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ova.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is ona`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ona.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is movie`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/movie.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is music is mapped to special`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/music.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is null and is mapped to UNKNOWN`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `fixed number of episodes`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/39.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(39) + } + } + + @Test + fun `neither episodes nor nextairingepisode is set`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/neither_episodes_nor_nextairingepisode_is_set.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(0) + } + } + + @Test + fun `ongoing series for which the value has to be taken from nextairingepisode`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/ongoing.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(1082) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `picture is available, but anilist never provides a thumbnail so the thumbnail is the same as the picture`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_available.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) + } + } + + @Test + fun `picture is unavailable`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_unavailable.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `synonyms taken from titles and synonyms, ignoring null`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/synonyms/synonyms_from_titles_and_synonyms.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Prism Paradise", + "Tobidasu PriPara: Minna de Mezase! Idol\u2606Grand Prix", + "Tobidasu PuriPara: Mi~nna de Mezase! Idol\u2606Grand Prix", + "とびだすプリパラ み~んなでめざせ!アイドル☆グランプリ", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract correct id and build anime link correctly`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/sources/15689.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.sources.first()).isEqualTo(URI("https://anilist.co/anime/15689")) + } + } + } + + @Nested + inner class RelationsTests { + + @Test + fun `no adaption, no relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_no_relations.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no adaption, multiple relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anilist.co/anime/107298"), "SIDE_STORY"), + RelatedAnime(URI("https://anilist.co/anime/116147"), "SEQUEL"), + RelatedAnime(URI("https://anilist.co/anime/97857"), "PREQUEL"), + ) + } + } + + @Test + fun `one adaption, one relation`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://anilist.co/anime/2337"), "ALTERNATIVE") + ) + } + } + + @Test + fun `has adaption, multiple relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anilist.co/anime/100148"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/1081"), "SIDE_STORY"), + RelatedAnime(URI("https://anilist.co/anime/1301"), "PREQUEL"), + RelatedAnime(URI("https://anilist.co/anime/1302"), "CHARACTER"), + RelatedAnime(URI("https://anilist.co/anime/1491"), "CHARACTER"), + RelatedAnime(URI("https://anilist.co/anime/1645"), "SEQUEL"), + RelatedAnime(URI("https://anilist.co/anime/1676"), "SIDE_STORY"), + RelatedAnime(URI("https://anilist.co/anime/1706"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/17269"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/2202"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/2203"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/2470"), "SIDE_STORY"), + ) + } + } + + @Test + fun `has adaption, no relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'FINISHED' is mapped to 'FINISHED'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/finished.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `'RELEASING' is mapped to 'ONGOING'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/releasing.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'NOT_YET_RELEASED' is mapped to 'UPCOMING'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/not_yet_released.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'CANCELLED' is mapped to 'UNKNOWN'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/cancelled.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `null is mapped to 'UNKNOWN'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `put names of genres and tags as distinct list into the anime's tags`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/tags/tags.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "adventure", + "amnesia", + "anti-hero", + "crime", + "cult", + "cyberpunk", + "cyborg", + "drama", + "drugs", + "ensemble cast", + "episodic", + "gambling", + "gender bending", + "guns", + "male protagonist", + "martial arts", + "noir", + "philosophy", + "police", + "primarily adult cast", + "sci-fi", + "space", + "tanned skin", + "terrorism", + "tomboy", + "tragedy", + "travel", + "yakuza", + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `duration is not set and therefore 0`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `duration is set to 0`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/0.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `anilist only uses minutes for duration - so this entry although 15 seconds long is set to 1 min`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/min_duration.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `duration of 24 minutes`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/24.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) + } + } + + @Test + fun `duration of 2 hours`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/120.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @Test + fun `season is 'undefined'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'spring'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/spring.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'summer'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/summer.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'fall'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/fall.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'wimter'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/winter.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `seasonYear is set`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/seasonyear_set.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `year is not set and default is 0`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(0) + } + } + + @Test + fun `year is 2006 - seasonYear is null but start date is set`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_2006.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(2006) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + // when + val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/score.json")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(9.090909090909092) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + // when + val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/no-score.json")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/multiple_studios.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/no_studios.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testAnilistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = + loadTestResource<String>("AnilistAnimeConverterTest/producers/multiple_producers.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "mixer", + "north stars pictures", + "jy animation", + "crunchyroll", + "ca-cygames anime fund", + "movic", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/producers/no_producers.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = AnilistAnimeConverter.instance + + // when + val result = AnilistAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnilistAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt b/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt index 038d1805..17a7d40e 100644 --- a/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt +++ b/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt @@ -1,316 +1,321 @@ -package io.github.manamiproject.modb.animeplanet - -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import kotlinx.coroutines.withContext -import java.net.URI -import java.time.Clock -import java.time.LocalDate - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property xmlExtractor Extractor which retrieves the data from raw data. - * @property jsonExtractor Extractor which retrieves the data from raw data. - * @property clock Instance of a clock to determine the current date. - */ -public class AnimePlanetAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnimePlanetConfig, - private val xmlExtractor: DataExtractor = XmlDataExtractor, - private val jsonExtractor: DataExtractor = JsonDataExtractor, - private val clock: Clock = Clock.systemUTC(), -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = xmlExtractor.extract(rawContent, mapOf( - "titleH1" to "//h1[@itemprop='name']/text()", - "jsonld" to "//script[@type='application/ld+json']/node()", - "titleMeta" to "//meta[@property='og:title']/@content", - "thumbnail" to "//img[@itemprop='image']/@src", - "typeEpisodesDuration" to "//span[@class='type']/text()", - "iconYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/text()", - "seasonYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/following-sibling::*/text()", - "source" to "//link[@rel='canonical']/@href", - "alternativeTitle" to "//h2[@class='aka']/text()", - "relatedAnime" to "//div[@id='tabs--relations--anime']/div//a/@href", - "tags" to "//div[contains(@class, 'tags')]//a/text()", - "studios" to "//a[contains(@href, '/studios/')]/text()", - )) - - val jsonld = data.string("jsonld") - val jsonldData = jsonExtractor.extract(jsonld, mapOf( - "title" to "$.name", - "alternateName" to "$.alternateName", - "source" to "$.url", - "image" to "$.image", - "datePublished" to "$.datePublished", - "genre" to "$.genre", - "worstRating" to "$.aggregateRating.worstRating", - "bestRating" to "$.aggregateRating.bestRating", - "ratingValue" to "$.aggregateRating.ratingValue", - )) - - val thumbnail = extractThumbnail(jsonldData, data) - - return@withContext AnimeRaw( - _title = extractTitle(jsonldData, data), - episodes = extractEpisodes(data), - type = extractType(data), - picture = extractPicture(thumbnail), - thumbnail = thumbnail, - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(jsonldData, data), - _sources = extractSourcesEntry(jsonldData, data), - _synonyms = extractSynonyms(jsonldData, data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(jsonldData, data), - _studios = extractStudios(data), - _producers = hashSetOf(), // not available on anime-planet - ).addScores(extractScore(jsonldData)) - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val value = data.stringOrDefault("iconYear") - val currentYear = LocalDate.now(clock).year - - // shows only one year - if (REGEX_YEAR.matches(value)) { - val year = value.toInt() - return when { - year > currentYear -> UPCOMING - year < currentYear -> FINISHED - else -> UNKNOWN_STATUS - } - } - - // shows range of two years - if ("""\d{4} - \d{4}""".toRegex().matches(value)) { - val year = REGEX_YEAR.findAll(value).last().value.toInt() - return when { - year > currentYear -> UPCOMING - year < currentYear -> FINISHED - else -> UNKNOWN_STATUS - } - } - - // shows range from year to unknown - if ("""\d{4} - \?""".toRegex().matches(value)) { - val year = REGEX_YEAR.find(value)!!.value.toInt() - return when { - year > currentYear -> UPCOMING - year < currentYear -> ONGOING - else -> UNKNOWN_STATUS - } - } - - // shows only TBA - if (value == "TBA") { - return UPCOMING - } - - throw IllegalStateException("Unknown case for status [$value]") - } - - private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { - var title = data.stringOrDefault("titleH1") - - if (title.contains(TITLE_CONTAINING_AT_CHAR)) { - title = jsonldData.stringOrDefault("title") - } - - if ((title.eitherNullOrBlank() || title.contains(TITLE_CONTAINING_AT_CHAR))) { - title = data.stringOrDefault("titleMeta") - } - - return title - } - - private fun extractEpisodes(data: ExtractionResult): Episodes { - return data.stringOrDefault("typeEpisodesDuration").let { - """\d+""".toRegex().find(it)?.value?.toInt() ?: 0 - } - } - - private fun extractType(data: ExtractionResult): AnimeType { - val textValue = data.string("typeEpisodesDuration").substringBefore('(').lowercase().let { - """([a-z]| )+""".toRegex().find(it)?.value?.trim() ?: EMPTY - } - - return when(textValue) { - "dvd special" -> SPECIAL - "movie" -> MOVIE - "music video" -> SPECIAL - "other" -> SPECIAL - "ova" -> OVA - "tv" -> TV - "tv special" -> SPECIAL - "web" -> ONA - else -> throw IllegalStateException("Unknown type [$textValue]") - } - } - - private fun extractThumbnail(jsonldData: ExtractionResult, data: ExtractionResult): URI { - val textValue = data.stringOrDefault("thumbnail").ifBlank { - jsonldData.stringOrDefault("image") - } - - return if (textValue.neitherNullNorBlank()) { - URI(textValue.substringBefore("?t=")) - } else { - NO_PICTURE - } - } - - private fun extractPicture(thumbnail: URI): URI { - return if (thumbnail != NO_PICTURE) { - URI(thumbnail.toString().replace("""-\d+x\d+""".toRegex(), EMPTY)) - } else { - NO_PICTURE_THUMBNAIL - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationInMinutes = data.string("typeEpisodesDuration").let { - """\d+ min""".toRegex().find(it)?.value?.remove("min")?.trim()?.toInt() ?: 0 - } - - return Duration(durationInMinutes, MINUTES) - } - - private fun extractAnimeSeason(jsonldData: ExtractionResult, data: ExtractionResult): AnimeSeason { - val yearStr = data.stringOrDefault("iconYear").ifBlank { - jsonldData.stringOrDefault("datePublished") - }.trim() - - val year = when { - REGEX_YEAR.matches(yearStr) -> yearStr.toInt() - yearStr.contains("-") -> { - val split = yearStr.split("-").first().trim() - if (REGEX_YEAR.matches(split)) { - split.toInt() - } else { - 0 - } - } - else -> 0 - } - - val season = data.string("seasonYear").trim().split(' ') - - return when { - season.size != 2 -> AnimeSeason(season = UNDEFINED, year = year) - else -> AnimeSeason(season = AnimeSeason.Season.of(season[0]), year = season[1].toInt()) - } - } - - private fun extractSourcesEntry(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<URI> { - val uri = data.stringOrDefault("source").ifBlank { - jsonldData.stringOrDefault("source") - }.trim().remove("www.") - - return hashSetOf(URI(uri)) - } - - private fun extractSynonyms(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Title> { - val heading = data.string("alternativeTitle") - - val alternativeTitles = hashSetOf<String>() - - when { - heading.startsWith(SINGLE_SYNONYM) -> alternativeTitles.add(heading.remove(SINGLE_SYNONYM).trim()) - heading.startsWith(MULTIPLE_SYNONYMS) -> heading.remove(MULTIPLE_SYNONYMS). - split(',') - .map { it.trim() } - .forEach { alternativeTitles.add(it) } - } - - if (alternativeTitles.any { it.contains(TITLE_CONTAINING_AT_CHAR) }) { - alternativeTitles.removeIf { it.contains(TITLE_CONTAINING_AT_CHAR) } - jsonldData.listNotNull<Title>("alternateName").forEach { alternativeTitles.add(it) } - } - - return alternativeTitles - } - - private fun extractTags(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { - val tags = if (jsonldData.notFound("genre")) { - hashSetOf() - } else { - jsonldData.listNotNull<Tag>("genre").map { it.trimEnd(',') }.toHashSet() - } - - if (!data.notFound("tags")) { - data.listNotNull<Tag>("tags").map { it.trimEnd(',') }.forEach { tags.add(it) } - } - - return tags - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> { - return if (data.notFound("relatedAnime")) { - hashSetOf() - } else { - data.listNotNull<String>("relatedAnime") - .map { it.remove("/anime/") } - .map { metaDataProviderConfig.buildAnimeLink(it) } - .toHashSet() - } - } - - private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { - if (jsonldData.notFound("ratingValue")) { - return NoMetaDataProviderScore - } - - val rawScore = jsonldData.double("ratingValue") - val from = jsonldData.doubleOrDefault("worstRating", 0.5) - val to = jsonldData.doubleOrDefault("bestRating", 5.0) - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = from..to, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - public companion object { - private val REGEX_YEAR = """\d{4}""".toRegex() - private const val TITLE_CONTAINING_AT_CHAR= "[email protected]" - private const val SINGLE_SYNONYM = "Alt title:" - private const val MULTIPLE_SYNONYMS = "Alt titles:" - - /** - * Singleton of [AnimePlanetAnimeConverter] - * @since 1.0.0 - */ - public val instance: AnimePlanetAnimeConverter by lazy { AnimePlanetAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.animeplanet + +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import kotlinx.coroutines.withContext +import java.net.URI +import java.time.Clock +import java.time.LocalDate + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property xmlExtractor Extractor which retrieves the data from raw data. + * @property jsonExtractor Extractor which retrieves the data from raw data. + * @property clock Instance of a clock to determine the current date. + */ +public class AnimePlanetAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnimePlanetConfig, + private val xmlExtractor: DataExtractor = XmlDataExtractor, + private val jsonExtractor: DataExtractor = JsonDataExtractor, + private val clock: Clock = Clock.systemUTC(), +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = xmlExtractor.extract(rawContent, mapOf( + "titleH1" to "//h1[@itemprop='name']/text()", + "jsonld" to "//script[@type='application/ld+json']/node()", + "titleMeta" to "//meta[@property='og:title']/@content", + "thumbnail" to "//img[@itemprop='image']/@src", + "typeEpisodesDuration" to "//span[@class='type']/text()", + "iconYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/text()", + "seasonYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/following-sibling::*/text()", + "source" to "//link[@rel='canonical']/@href", + "alternativeTitle" to "//h2[@class='aka']/text()", + "relatedAnime" to "//div[@id='tabs--relations--anime']/div//a/html()", + "tags" to "//div[contains(@class, 'tags')]//a/text()", + "studios" to "//a[contains(@href, '/studios/')]/text()", + )) + + val jsonld = data.string("jsonld") + val jsonldData = jsonExtractor.extract(jsonld, mapOf( + "title" to "$.name", + "alternateName" to "$.alternateName", + "source" to "$.url", + "image" to "$.image", + "datePublished" to "$.datePublished", + "genre" to "$.genre", + "worstRating" to "$.aggregateRating.worstRating", + "bestRating" to "$.aggregateRating.bestRating", + "ratingValue" to "$.aggregateRating.ratingValue", + )) + + val thumbnail = extractThumbnail(jsonldData, data) + + return@withContext AnimeRaw( + _title = extractTitle(jsonldData, data), + episodes = extractEpisodes(data), + type = extractType(data), + picture = extractPicture(thumbnail), + thumbnail = thumbnail, + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(jsonldData, data), + _sources = extractSourcesEntry(jsonldData, data), + _synonyms = extractSynonyms(jsonldData, data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(jsonldData, data), + _studios = extractStudios(data), + _producers = hashSetOf(), // not available on anime-planet + ).addScores(extractScore(jsonldData)) + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val value = data.stringOrDefault("iconYear") + val currentYear = LocalDate.now(clock).year + + // shows only one year + if (REGEX_YEAR.matches(value)) { + val year = value.toInt() + return when { + year > currentYear -> UPCOMING + year < currentYear -> FINISHED + else -> UNKNOWN_STATUS + } + } + + // shows range of two years + if ("""\d{4} - \d{4}""".toRegex().matches(value)) { + val year = REGEX_YEAR.findAll(value).last().value.toInt() + return when { + year > currentYear -> UPCOMING + year < currentYear -> FINISHED + else -> UNKNOWN_STATUS + } + } + + // shows range from year to unknown + if ("""\d{4} - \?""".toRegex().matches(value)) { + val year = REGEX_YEAR.find(value)!!.value.toInt() + return when { + year > currentYear -> UPCOMING + year < currentYear -> ONGOING + else -> UNKNOWN_STATUS + } + } + + // shows only TBA + if (value == "TBA") { + return UPCOMING + } + + throw IllegalStateException("Unknown case for status [$value]") + } + + private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { + var title = data.stringOrDefault("titleH1") + + if (title.contains(TITLE_CONTAINING_AT_CHAR)) { + title = jsonldData.stringOrDefault("title") + } + + if ((title.eitherNullOrBlank() || title.contains(TITLE_CONTAINING_AT_CHAR))) { + title = data.stringOrDefault("titleMeta") + } + + return title + } + + private fun extractEpisodes(data: ExtractionResult): Episodes { + return data.stringOrDefault("typeEpisodesDuration").let { + """\d+""".toRegex().find(it)?.value?.toInt() ?: 0 + } + } + + private fun extractType(data: ExtractionResult): AnimeType { + val textValue = data.string("typeEpisodesDuration").substringBefore('(').lowercase().let { + """([a-z]| )+""".toRegex().find(it)?.value?.trim() ?: EMPTY + } + + return when(textValue) { + "dvd special" -> SPECIAL + "movie" -> MOVIE + "music video" -> SPECIAL + "other" -> SPECIAL + "ova" -> OVA + "tv" -> TV + "tv special" -> SPECIAL + "web" -> ONA + else -> throw IllegalStateException("Unknown type [$textValue]") + } + } + + private fun extractThumbnail(jsonldData: ExtractionResult, data: ExtractionResult): URI { + val textValue = data.stringOrDefault("thumbnail").ifBlank { + jsonldData.stringOrDefault("image") + } + + return if (textValue.neitherNullNorBlank()) { + URI(textValue.substringBefore("?t=")) + } else { + NO_PICTURE + } + } + + private fun extractPicture(thumbnail: URI): URI { + return if (thumbnail != NO_PICTURE) { + URI(thumbnail.toString().replace("""-\d+x\d+""".toRegex(), EMPTY)) + } else { + NO_PICTURE_THUMBNAIL + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationInMinutes = data.string("typeEpisodesDuration").let { + """\d+ min""".toRegex().find(it)?.value?.remove("min")?.trim()?.toInt() ?: 0 + } + + return Duration(durationInMinutes, MINUTES) + } + + private fun extractAnimeSeason(jsonldData: ExtractionResult, data: ExtractionResult): AnimeSeason { + val yearStr = data.stringOrDefault("iconYear").ifBlank { + jsonldData.stringOrDefault("datePublished") + }.trim() + + val year = when { + REGEX_YEAR.matches(yearStr) -> yearStr.toInt() + yearStr.contains("-") -> { + val split = yearStr.split("-").first().trim() + if (REGEX_YEAR.matches(split)) { + split.toInt() + } else { + 0 + } + } + else -> 0 + } + + val season = data.string("seasonYear").trim().split(' ') + + return when { + season.size != 2 -> AnimeSeason(season = UNDEFINED, year = year) + else -> AnimeSeason(season = AnimeSeason.Season.of(season[0]), year = season[1].toInt()) + } + } + + private fun extractSourcesEntry(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<URI> { + val uri = data.stringOrDefault("source").ifBlank { + jsonldData.stringOrDefault("source") + }.trim().remove("www.") + + return hashSetOf(URI(uri)) + } + + private fun extractSynonyms(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Title> { + val heading = data.string("alternativeTitle") + + val alternativeTitles = hashSetOf<String>() + + when { + heading.startsWith(SINGLE_SYNONYM) -> alternativeTitles.add(heading.remove(SINGLE_SYNONYM).trim()) + heading.startsWith(MULTIPLE_SYNONYMS) -> heading.remove(MULTIPLE_SYNONYMS). + split(',') + .map { it.trim() } + .forEach { alternativeTitles.add(it) } + } + + if (alternativeTitles.any { it.contains(TITLE_CONTAINING_AT_CHAR) }) { + alternativeTitles.removeIf { it.contains(TITLE_CONTAINING_AT_CHAR) } + jsonldData.listNotNull<Title>("alternateName").forEach { alternativeTitles.add(it) } + } + + return alternativeTitles + } + + private fun extractTags(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { + val tags = if (jsonldData.notFound("genre")) { + hashSetOf() + } else { + jsonldData.listNotNull<Tag>("genre").map { it.trimEnd(',') }.toHashSet() + } + + if (!data.notFound("tags")) { + data.listNotNull<Tag>("tags").map { it.trimEnd(',') }.forEach { tags.add(it) } + } + + return tags + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("relatedAnime")) { + return hashSetOf() + } + + // <a href="/anime/..." class="RelatedEntry ..."> ... <span class="RelatedEntry__subtitle ...">Side Story</span> + // The subtitle is optional. + return xmlExtractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnime"), + labelSelector = "//span[contains(@class, 'RelatedEntry__subtitle')]/text()", + linkSelector = "//a/@href", + ).flatMap { entry -> entry.links.map { RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(it.remove("/anime/")), entry.label) } } + .toHashSet() + } + + private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { + if (jsonldData.notFound("ratingValue")) { + return NoMetaDataProviderScore + } + + val rawScore = jsonldData.double("ratingValue") + val from = jsonldData.doubleOrDefault("worstRating", 0.5) + val to = jsonldData.doubleOrDefault("bestRating", 5.0) + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = from..to, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + public companion object { + private val REGEX_YEAR = """\d{4}""".toRegex() + private const val TITLE_CONTAINING_AT_CHAR= "[email protected]" + private const val SINGLE_SYNONYM = "Alt title:" + private const val MULTIPLE_SYNONYMS = "Alt titles:" + + /** + * Singleton of [AnimePlanetAnimeConverter] + * @since 1.0.0 + */ + public val instance: AnimePlanetAnimeConverter by lazy { AnimePlanetAnimeConverter() } + } +} \ No newline at end of file diff --git a/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt b/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt index a399d66f..48eb7a4b 100644 --- a/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt +++ b/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt @@ -1,1260 +1,1261 @@ -package io.github.manamiproject.modb.animeplanet - -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import java.time.Clock -import java.time.LocalDate -import java.time.ZoneOffset.UTC -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS - -internal class AnimePlanetAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/title/special_chars.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("PriPara Movie: Minna no Akogare♪ Let's Go☆Prix Paris") - } - } - - @Test - fun `extract title from jsonld if it was replaces by 'email protected'`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("Malice@Doll") - } - } - - @Test - fun `extract title from meta tag if it was replaces by 'email protected' and jsonld does not exist`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected_no_jsonld.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("The iDOLM@STER Million Live!") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `1 episode`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1_ep.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `1081 and more episodes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1081+_eps.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(1081) - } - } - - @Test - fun `25 episodes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/25_eps.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(25) - } - } - - @Test - fun `no episodes is mapped to 1 episode`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/no_episodes.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isZero() - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `'web' is mapped to ONA`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/web.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `'TV Special' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv_special.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'TV' is mapped to TV`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `'OVA' is mapped to OVA`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/ova.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `'other' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/other.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'music video' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/music_video.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'movie' is mapped to Movie`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/movie.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `'dvd special' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/dvd_special.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `value without braces is extracted correctly`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/no_braces.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `correctly extract picture and thumbnail`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1.jpg") - assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1-190x242.jpg") - } - } - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") - assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") - } - } - - @Test - fun `ensure that the primary screenshot is extracted as picture`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/ensure_primary_screenshot_as_picture.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1.webp") - assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1-285x399.webp") - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'tba' is mapped to UPCOMING`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/tba.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `current year is mapped to UNKNOWN, because it is not possible to determine whether the anime has finished or is still running`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_is_current_year.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `year in the future is mapped to UPCOMING`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_future.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `year in the past is mapped to FINISHED`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_past.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `range starting in the past is mapped to ONGOING`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_in_the_past.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `range starting in the same year is mapped to UNKNOWN, because it is not possible to determine whether the anime has already started or not`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_same_year.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `range ending in the past`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_in_the_past.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `range ending same year`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_same_year.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hour`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_hour.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 min`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_min.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `2 hours`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `2 hours 15 minutes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours_15_min.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) - } - } - - @Test - fun `10 minutes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/10_min.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Test - fun `season is undefined, but year is set`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/year_only.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 2016)) - } - } - - @Test - fun `season is summer`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/summer.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(SUMMER, 2020)) - } - } - - @Test - fun `season is spring`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/spring.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(SPRING, 2020)) - } - } - - @Test - fun `season is winter`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/winter.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(WINTER, 2020)) - } - } - - @Test - fun `season is fall`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/fall.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(FALL, 2020)) - } - } - - @Test - fun `tba - no year, no season`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/tba.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 0)) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract url slug as id`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/sources/id.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.sources).containsExactly(URI("https://anime-planet.com/anime/code-geass-lelouch-of-the-rebellion-r2")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `successfully extract the alternative title`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_available.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactly("Kimi no Na wa.") - } - } - - @Test - fun `alternative title is not available`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_not_available.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `extract synonym from jsonld if it was replaces by 'email protected'`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_replaced_by_email_protected.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactly("GJ Club@") - } - } - - @Test - fun `extract multiple synonyms`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/multiple_synonyms.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "\"Space Battleship Yamato\" Era: The Choice in 2202", - "\"Uchuu Senkan Yamato\" to Iu Jidai: Seireki 2202-nen no Sentaku", - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `extract same franchise and other franchise`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_and_other_franchise.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://anime-planet.com/anime/fullmetal-alchemist"), - URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-4-koma-theater"), - URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-specials"), - URI("https://anime-planet.com/anime/fullmetal-alchemist-premium-collection"), - URI("https://anime-planet.com/anime/fullmetal-alchemist-reflections"), - URI("https://anime-planet.com/anime/fullmetal-alchemist-the-movie-conqueror-of-shamballa"), - URI("https://anime-planet.com/anime/fullmetal-alchemist-the-sacred-star-of-milos"), - ) - } - } - - @Test - fun `extract related anime if there are only same franchise entries`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_only.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-entertainment-district-arc"), - URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-movie-mugen-train"), - URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-mugen-train"), - URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-swordsmith-village-arc"), - URI("https://anime-planet.com/anime/kimetsu-gakuen-monogatari-kimetsu-no-utage-tokubetsu-hen"), - URI("https://anime-planet.com/anime/kimetsu-gakuen-valentine-hen"), - ) - } - } - - @Test - fun `no related anime`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/no_related_anime.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `successfully extract the tags`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/various_tags.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "based on a manga", - "child protagonists", - "dark fantasy", - "fantasy", - "horror", - "isolated society", - "mind games", - "mystery", - "noitamina", - "orphanage", - "orphans", - "outside world", - "psychological", - "sci fi", - "shounen", - "thriller", - "domestic abuse", - "self-harm", - "mature themes", - ) - } - } - - @Test - fun `tags not available`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/no_tags.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/score.html")) - - // then - assertThat(result.scores).hasSize(1) - assertThat((result.scores.first()).scaledValue()).isEqualTo(8.844) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/no-score.html")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/multiple_studios.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/no_studios.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = AnimePlanetAnimeConverter.instance - - // when - val result = AnimePlanetAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnimePlanetAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.animeplanet + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import java.time.Clock +import java.time.LocalDate +import java.time.ZoneOffset.UTC +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS + +internal class AnimePlanetAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/title/special_chars.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("PriPara Movie: Minna no Akogare♪ Let's Go☆Prix Paris") + } + } + + @Test + fun `extract title from jsonld if it was replaces by 'email protected'`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("Malice@Doll") + } + } + + @Test + fun `extract title from meta tag if it was replaces by 'email protected' and jsonld does not exist`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected_no_jsonld.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("The iDOLM@STER Million Live!") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `1 episode`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1_ep.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `1081 and more episodes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1081+_eps.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(1081) + } + } + + @Test + fun `25 episodes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/25_eps.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(25) + } + } + + @Test + fun `no episodes is mapped to 1 episode`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/no_episodes.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isZero() + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `'web' is mapped to ONA`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/web.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `'TV Special' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv_special.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'TV' is mapped to TV`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `'OVA' is mapped to OVA`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/ova.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `'other' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/other.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'music video' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/music_video.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'movie' is mapped to Movie`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/movie.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `'dvd special' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/dvd_special.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `value without braces is extracted correctly`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/no_braces.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `correctly extract picture and thumbnail`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1.jpg") + assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1-190x242.jpg") + } + } + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") + assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") + } + } + + @Test + fun `ensure that the primary screenshot is extracted as picture`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/ensure_primary_screenshot_as_picture.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1.webp") + assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1-285x399.webp") + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'tba' is mapped to UPCOMING`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/tba.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `current year is mapped to UNKNOWN, because it is not possible to determine whether the anime has finished or is still running`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_is_current_year.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `year in the future is mapped to UPCOMING`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_future.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `year in the past is mapped to FINISHED`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_past.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `range starting in the past is mapped to ONGOING`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_in_the_past.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `range starting in the same year is mapped to UNKNOWN, because it is not possible to determine whether the anime has already started or not`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_same_year.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `range ending in the past`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_in_the_past.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `range ending same year`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_same_year.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hour`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_hour.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 min`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_min.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `2 hours`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `2 hours 15 minutes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours_15_min.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) + } + } + + @Test + fun `10 minutes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/10_min.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Test + fun `season is undefined, but year is set`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/year_only.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 2016)) + } + } + + @Test + fun `season is summer`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/summer.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(SUMMER, 2020)) + } + } + + @Test + fun `season is spring`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/spring.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(SPRING, 2020)) + } + } + + @Test + fun `season is winter`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/winter.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(WINTER, 2020)) + } + } + + @Test + fun `season is fall`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/fall.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(FALL, 2020)) + } + } + + @Test + fun `tba - no year, no season`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/tba.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 0)) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract url slug as id`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/sources/id.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.sources).containsExactly(URI("https://anime-planet.com/anime/code-geass-lelouch-of-the-rebellion-r2")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `successfully extract the alternative title`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_available.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactly("Kimi no Na wa.") + } + } + + @Test + fun `alternative title is not available`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_not_available.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `extract synonym from jsonld if it was replaces by 'email protected'`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_replaced_by_email_protected.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactly("GJ Club@") + } + } + + @Test + fun `extract multiple synonyms`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/multiple_synonyms.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "\"Space Battleship Yamato\" Era: The Choice in 2202", + "\"Uchuu Senkan Yamato\" to Iu Jidai: Seireki 2202-nen no Sentaku", + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `extract same franchise and other franchise`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_and_other_franchise.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-4-koma-theater"), "Omake"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-specials"), "Side Story"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-premium-collection"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-reflections"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-the-movie-conqueror-of-shamballa"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-the-sacred-star-of-milos")), + ) + } + } + + @Test + fun `extract related anime if there are only same franchise entries`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_only.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-entertainment-district-arc")), + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-movie-mugen-train"), "Sequel"), + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-mugen-train"), "Sequel"), + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-swordsmith-village-arc")), + RelatedAnime(URI("https://anime-planet.com/anime/kimetsu-gakuen-monogatari-kimetsu-no-utage-tokubetsu-hen")), + RelatedAnime(URI("https://anime-planet.com/anime/kimetsu-gakuen-valentine-hen")), + ) + } + } + + @Test + fun `no related anime`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/no_related_anime.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `successfully extract the tags`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/various_tags.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "based on a manga", + "child protagonists", + "dark fantasy", + "fantasy", + "horror", + "isolated society", + "mind games", + "mystery", + "noitamina", + "orphanage", + "orphans", + "outside world", + "psychological", + "sci fi", + "shounen", + "thriller", + "domestic abuse", + "self-harm", + "mature themes", + ) + } + } + + @Test + fun `tags not available`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/no_tags.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/score.html")) + + // then + assertThat(result.scores).hasSize(1) + assertThat((result.scores.first()).scaledValue()).isEqualTo(8.844) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/no-score.html")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/multiple_studios.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/no_studios.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = AnimePlanetAnimeConverter.instance + + // when + val result = AnimePlanetAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnimePlanetAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt b/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt index 4be03d00..9dbc55cd 100644 --- a/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt +++ b/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt @@ -1,451 +1,463 @@ -package io.github.manamiproject - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.normalize -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import kotlinx.coroutines.withContext -import java.net.URI -import java.time.Clock -import java.time.LocalDate -import kotlin.time.DurationUnit -import kotlin.time.DurationUnit.SECONDS -import kotlin.time.toDuration -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML from the mobile site version. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extracts specific data from raw content. - */ -public class AnimenewsnetworkAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnimenewsnetworkConfig, - private val extractor: DataExtractor = XmlDataExtractor, - private val clock: Clock = Clock.systemUTC(), -): AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract(rawContent, mapOf( - "source" to "//link[@rel='canonical']/@href", - "title" to "//h1[@id='page_header']/text()", - "numberOfEpisodes" to "//strong[text()='Number of episodes:']/following-sibling::span/text()", - "episodeTitles" to "//div[@id='infotype-25']//a/text()", - "score" to "//b[text()='Arithmetic mean:']/..", - "picture" to "//div[@id='infotype-19']//img/@src", - "year" to "//div[@id='infotype-7']//span/text()", - "year_list_first" to "//div[@id='infotype-7']//div[1]", - "synonyms" to "//div[@id='infotype-2']//div", - "genres" to "//div[@id='infotype-30']//a/text()", - "themes" to "//div[@id='infotype-31']//a/text()", - "relatedAnime" to "//div[@id='infotype-related']//a/@href", - "duration" to "//div[@id='infotype-4']//span/text()", - "year_list_all" to "//div[@id='infotype-7']//div//text()", - )) - - val japaneseCompanies = rawContent.substringAfter("<nobr>Japanese companies</nobr>") - .substringBefore("<nobr>") - - val productionData = extractor.extract(japaneseCompanies, mapOf( - "studios" to "//b[text()='Animation Production']/following-sibling::a/text()", - "producers" to "//b[text()='Production']/following-sibling::a/text()", - )) - - val extractedTitle = extractTitle(data) - - return@withContext AnimeRaw( - _title = extractedTitle.first, - _sources = extractSourcesEntry(data), - episodes = extractEpisodes(data), - type = extractType(data), - status = extractStatus(data), - picture = extractPicture(data), - thumbnail = extractThumbnail(data), - animeSeason = extractAnimeSeason(data), - duration = extractDuration(data), - _synonyms = extractSynonyms(data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(productionData), - _producers = extractProducers(productionData), - ).addScores(extractScore(data)) - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - return hashSetOf(URI(data.string("source").remove("www."))) - } - - private fun extractTitle(data: ExtractionResult): Pair<Title, Title> { - val rawTitle = data.string("title").normalize() - - return when { - rawTitle.endsWith(")") -> rawTitle.substringBeforeLast('(') to rawTitle - else -> rawTitle.substringBeforeLast('(') to EMPTY - } - } - - private fun extractEpisodes(data: ExtractionResult): Episodes { - var episodes = data.intOrDefault("numberOfEpisodes") - - if (episodes == 0 && !data.notFound("episodeTitles")) { - episodes = data.string("episodeTitles").remove("We have ").toIntOrNull() ?: 0 - } - - if (episodes == 0 && data.stringOrDefault("title").lowercase().contains("movie")) { - episodes = 1 - } - - return episodes - } - - private fun extractType(data: ExtractionResult): AnimeType { - val title = data.stringOrDefault("title").lowercase() - val type = title.substringAfterLast('(') - - return when { - type.contains("tv") -> TV - type.contains("movie") -> MOVIE - type.contains("oav") -> OVA - type.contains("ona") -> ONA - type.contains("special") -> SPECIAL - else -> when { - title.contains("motion picture") -> MOVIE - title.contains("ova") -> OVA - else -> UNKNOWN_TYPE - } - } - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.listNotNull<String>("score") - .first { it.contains("std. dev.") } - .substringBefore('(') - .normalize() - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore.toDoubleOrNull() ?: 0.0, - range = 1.0..10.0, - ) - } - - private fun extractThumbnail(data: ExtractionResult): URI { - if (data.notFound("picture")) { - return NO_PICTURE_THUMBNAIL - } - - val link = data.listNotNull<String>("picture").first() - val id = link.substringAfterLast("encyc/").normalize() - return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/fit200x200/encyc/$id") - } - - private fun extractPicture(data: ExtractionResult): URI { - if (data.notFound("picture")) { - return NO_PICTURE - } - - val link = data.listNotNull<String>("picture").first() - val id = link.substringAfterLast("encyc/").normalize() - return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/max500x600/encyc/$id") - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val yearExists = !data.notFound("year") - val yearListExists = !data.notFound("year_list_first") - - if (!yearExists && !yearListExists) { - return unknown_season - } - - // 1 Extraction from year - var matchResult = yearMonthRegex.matchEntire(data.string("year").substringBefore("to").normalize()) - - if ((matchResult == null || matchResult.groups.isEmpty()) && !yearListExists) { - return unknown_season - } - - var year = (matchResult?.groups?.get("year")?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR - var month = matchResult?.groups?.get("month")?.value?.toIntOrNull() ?: 0 - - // 2 Unable to extract year and no other options left - if (year == UNKNOWN_YEAR && !yearListExists) { - return unknown_season - } - - // 3 Extract from yearList - if (year == UNKNOWN_YEAR) { - matchResult = yearMonthRegex.matchEntire(data.string("year_list_first").substringBefore("to").normalize()) - - if (matchResult == null || matchResult.groups.isEmpty()) { - return unknown_season - } - - year = (matchResult.groups["year"]?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR - month = matchResult.groups["month"]?.value?.toIntOrNull() ?: 0 - } - - val season = when(month) { - 1, 2, 3 -> WINTER - 4, 5, 6 -> SPRING - 7, 8, 9 -> SUMMER - 10, 11, 12 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - year = year, - season = season, - ) - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - if (data.notFound("synonyms")) { - return hashSetOf() - } - - return data.listNotNull<Title>("synonyms") - .map { it.normalize() } - .map { title -> - val endsWithBracket = title.endsWith(')') - - if (endsWithBracket) { - var closingBrackets = 1 - var openingBracketSeen = 0 - var rangeEnd = title.length - 1 - - for (i in title.length - 2 downTo 0 step 1) { - when { - title[i] == ')' -> { - closingBrackets++ - rangeEnd = i - } - title[i] == '(' && openingBracketSeen != closingBrackets -> { - openingBracketSeen++ - rangeEnd = i - } - openingBracketSeen != closingBrackets -> rangeEnd = i - openingBracketSeen == closingBrackets && title[i] == ' ' -> rangeEnd = i - openingBracketSeen == closingBrackets && title[i] !in new_language_lock_identifiers -> break - } - } - - title.substring(0, rangeEnd) - } else { - title - } - } - .toHashSet() - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - val genres = if (data.notFound("genres")) { - hashSetOf() - } else { - data.listNotNull<Tag>("genres") - } - - val themes = if (data.notFound("themes")) { - hashSetOf() - } else { - data.listNotNull<Tag>("themes") - } - - return genres.union(themes).toHashSet() - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> { - val entries = if (data.notFound("relatedAnime")) { - hashSetOf() - } else { - data.listNotNull<String>("relatedAnime") - } - - return entries.filter { it.contains("anime.php") } - .map { it.substringAfterLast('=') } - .map { metaDataProviderConfig.buildAnimeLink(it) } - .toHashSet() - } - - private fun extractDuration(data: ExtractionResult): Duration { - if (data.notFound("duration")) { - return UNKNOWN_DURATION - } - - val value = data.string("duration").normalize().lowercase() - - if (durationMinutesRegex.matches(value)) { - val minutes = durationMinutesRegex.matchEntire(value) - ?.groups - ?.get("minutes") - ?.value - ?.toIntOrNull() - ?: 0 - - return Duration( - value = minutes, - unit = MINUTES, - ) - } - - if (durationHalfHourTextRegex.matches(value)) { - return Duration( - value = 30, - unit = MINUTES, - ) - } - - if (durationOneHourTextRegex.matches(value)) { - return Duration( - value = 1, - unit = HOURS, - ) - } - - if (durationMinSecondsRegex.matches(value)) { - val groups = durationMinSecondsRegex.matchEntire(value) - ?.groups - - val minutes = Duration( - value = groups - ?.get("minutes") - ?.value - ?.toIntOrNull() - ?: 0, - unit = MINUTES, - ) - - val seconds = Duration( - value = groups - ?.get("seconds") - ?.value - ?.toIntOrNull() - ?: 0, - unit = TimeUnit.SECONDS, - ) - - return Duration( - value = minutes.duration + seconds.duration, - unit = TimeUnit.SECONDS, - ) - } - - throw IllegalStateException("Unknown value [$value] for duration.") - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val yearExists = !data.notFound("year") - val yearListAllExists = !data.notFound("year_list_all") - - if (!yearExists && !yearListAllExists) { - return UNKNOWN_STATUS - } - - val year = data.string("year").normalize() - val yearListAll = data.listNotNull<String>("year_list_all").map { it.normalize() } - - val value = when { - yearMonthDayRegex.containsMatchIn(year) -> year - else -> { - yearListAll.filterNot { it.startsWith("Vintage:") }.firstOrNull { it.contains(" to ") } ?: yearListAll.firstOrNull() ?: EMPTY - } - } - - val splitDates = value.split(" to ").map { it.normalize() }.filter { str -> setOf(yearMonthRegex, yearMonthDayRegex).any { it.matches(str) } } - val now = LocalDate.now(clock) - - // Single date - if (splitDates.size == 1) { - val endYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: 0 - val endMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 - val endDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 - - if (endYear == 0) { - return UNKNOWN_STATUS - } - - val endDate = LocalDate.of(endYear, endMonth, endDay) - - return when { - now.isBefore(endDate) -> UPCOMING - now.isAfter(endDate) -> FINISHED - else -> ONGOING - } - } - - val startYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify startYear.") - val startMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 - val startDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 - val startDate = LocalDate.of(startYear, startMonth, startDay) - - val endYear = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify endYear.") - val endMonth = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 - val endDay = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 - val endDate = LocalDate.of(endYear, endMonth, endDay) - - return when { - now.isBefore(startDate) -> UPCOMING - now.isAfter(endDate) -> FINISHED - (now.isEqual(startDate) || now.isAfter(startDate)) && (now.isBefore(endDate) || now.isEqual(endDate)) -> ONGOING - else -> UNKNOWN_STATUS - } - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return if (data.notFound("producers")) { - hashSetOf() - } else { - data.listNotNull<Producer>("producers").toHashSet() - } - } - - public companion object { - /** - * Singleton of [AnimenewsnetworkAnimeConverter] - * @since 6.1.0 - */ - public val instance: AnimenewsnetworkAnimeConverter by lazy { AnimenewsnetworkAnimeConverter() } - - private val unknown_season = AnimeSeason( - year = UNKNOWN_YEAR, - season = UNDEFINED, - ) - - private val yearMonthRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?.*?""".toRegex() - private val yearMonthDayRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?(-(?<day>\d{2}))?.*""".toRegex() - private val new_language_lock_identifiers = hashSetOf(' ', ')') - private val durationMinutesRegex = """^.*?(?<minutes>\d+) minutes ?.*?$""".toRegex() - private val durationHalfHourTextRegex = """^.*?half hour ?.*?$""".toRegex() - private val durationOneHourTextRegex = """^.*?one hour ?.*?$""".toRegex() - private val durationMinSecondsRegex = """^(?<minutes>\d+) +min\. +(?<seconds>\d+)s +minutes( per episode)?$""".toRegex() - } -} \ No newline at end of file +package io.github.manamiproject + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.normalize +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import kotlinx.coroutines.withContext +import java.net.URI +import java.time.Clock +import java.time.LocalDate +import kotlin.time.DurationUnit +import kotlin.time.DurationUnit.SECONDS +import kotlin.time.toDuration +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML from the mobile site version. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extracts specific data from raw content. + */ +public class AnimenewsnetworkAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnimenewsnetworkConfig, + private val extractor: DataExtractor = XmlDataExtractor, + private val clock: Clock = Clock.systemUTC(), +): AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract(rawContent, mapOf( + "source" to "//link[@rel='canonical']/@href", + "title" to "//h1[@id='page_header']/text()", + "numberOfEpisodes" to "//strong[text()='Number of episodes:']/following-sibling::span/text()", + "episodeTitles" to "//div[@id='infotype-25']//a/text()", + "score" to "//b[text()='Arithmetic mean:']/..", + "picture" to "//div[@id='infotype-19']//img/@src", + "year" to "//div[@id='infotype-7']//span/text()", + "year_list_first" to "//div[@id='infotype-7']//div[1]", + "synonyms" to "//div[@id='infotype-2']//div", + "genres" to "//div[@id='infotype-30']//a/text()", + "themes" to "//div[@id='infotype-31']//a/text()", + "relatedAnime" to "//div[@id='infotype-related']/html()", + "duration" to "//div[@id='infotype-4']//span/text()", + "year_list_all" to "//div[@id='infotype-7']//div//text()", + )) + + val japaneseCompanies = rawContent.substringAfter("<nobr>Japanese companies</nobr>") + .substringBefore("<nobr>") + + val productionData = extractor.extract(japaneseCompanies, mapOf( + "studios" to "//b[text()='Animation Production']/following-sibling::a/text()", + "producers" to "//b[text()='Production']/following-sibling::a/text()", + )) + + val extractedTitle = extractTitle(data) + + return@withContext AnimeRaw( + _title = extractedTitle.first, + _sources = extractSourcesEntry(data), + episodes = extractEpisodes(data), + type = extractType(data), + status = extractStatus(data), + picture = extractPicture(data), + thumbnail = extractThumbnail(data), + animeSeason = extractAnimeSeason(data), + duration = extractDuration(data), + _synonyms = extractSynonyms(data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(productionData), + _producers = extractProducers(productionData), + ).addScores(extractScore(data)) + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + return hashSetOf(URI(data.string("source").remove("www."))) + } + + private fun extractTitle(data: ExtractionResult): Pair<Title, Title> { + val rawTitle = data.string("title").normalize() + + return when { + rawTitle.endsWith(")") -> rawTitle.substringBeforeLast('(') to rawTitle + else -> rawTitle.substringBeforeLast('(') to EMPTY + } + } + + private fun extractEpisodes(data: ExtractionResult): Episodes { + var episodes = data.intOrDefault("numberOfEpisodes") + + if (episodes == 0 && !data.notFound("episodeTitles")) { + episodes = data.string("episodeTitles").remove("We have ").toIntOrNull() ?: 0 + } + + if (episodes == 0 && data.stringOrDefault("title").lowercase().contains("movie")) { + episodes = 1 + } + + return episodes + } + + private fun extractType(data: ExtractionResult): AnimeType { + val title = data.stringOrDefault("title").lowercase() + val type = title.substringAfterLast('(') + + return when { + type.contains("tv") -> TV + type.contains("movie") -> MOVIE + type.contains("oav") -> OVA + type.contains("ona") -> ONA + type.contains("special") -> SPECIAL + else -> when { + title.contains("motion picture") -> MOVIE + title.contains("ova") -> OVA + else -> UNKNOWN_TYPE + } + } + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.listNotNull<String>("score") + .first { it.contains("std. dev.") } + .substringBefore('(') + .normalize() + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore.toDoubleOrNull() ?: 0.0, + range = 1.0..10.0, + ) + } + + private fun extractThumbnail(data: ExtractionResult): URI { + if (data.notFound("picture")) { + return NO_PICTURE_THUMBNAIL + } + + val link = data.listNotNull<String>("picture").first() + val id = link.substringAfterLast("encyc/").normalize() + return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/fit200x200/encyc/$id") + } + + private fun extractPicture(data: ExtractionResult): URI { + if (data.notFound("picture")) { + return NO_PICTURE + } + + val link = data.listNotNull<String>("picture").first() + val id = link.substringAfterLast("encyc/").normalize() + return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/max500x600/encyc/$id") + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val yearExists = !data.notFound("year") + val yearListExists = !data.notFound("year_list_first") + + if (!yearExists && !yearListExists) { + return unknown_season + } + + // 1 Extraction from year + var matchResult = yearMonthRegex.matchEntire(data.string("year").substringBefore("to").normalize()) + + if ((matchResult == null || matchResult.groups.isEmpty()) && !yearListExists) { + return unknown_season + } + + var year = (matchResult?.groups?.get("year")?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR + var month = matchResult?.groups?.get("month")?.value?.toIntOrNull() ?: 0 + + // 2 Unable to extract year and no other options left + if (year == UNKNOWN_YEAR && !yearListExists) { + return unknown_season + } + + // 3 Extract from yearList + if (year == UNKNOWN_YEAR) { + matchResult = yearMonthRegex.matchEntire(data.string("year_list_first").substringBefore("to").normalize()) + + if (matchResult == null || matchResult.groups.isEmpty()) { + return unknown_season + } + + year = (matchResult.groups["year"]?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR + month = matchResult.groups["month"]?.value?.toIntOrNull() ?: 0 + } + + val season = when(month) { + 1, 2, 3 -> WINTER + 4, 5, 6 -> SPRING + 7, 8, 9 -> SUMMER + 10, 11, 12 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + year = year, + season = season, + ) + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + if (data.notFound("synonyms")) { + return hashSetOf() + } + + return data.listNotNull<Title>("synonyms") + .map { it.normalize() } + .map { title -> + val endsWithBracket = title.endsWith(')') + + if (endsWithBracket) { + var closingBrackets = 1 + var openingBracketSeen = 0 + var rangeEnd = title.length - 1 + + for (i in title.length - 2 downTo 0 step 1) { + when { + title[i] == ')' -> { + closingBrackets++ + rangeEnd = i + } + title[i] == '(' && openingBracketSeen != closingBrackets -> { + openingBracketSeen++ + rangeEnd = i + } + openingBracketSeen != closingBrackets -> rangeEnd = i + openingBracketSeen == closingBrackets && title[i] == ' ' -> rangeEnd = i + openingBracketSeen == closingBrackets && title[i] !in new_language_lock_identifiers -> break + } + } + + title.substring(0, rangeEnd) + } else { + title + } + } + .toHashSet() + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + val genres = if (data.notFound("genres")) { + hashSetOf() + } else { + data.listNotNull<Tag>("genres") + } + + val themes = if (data.notFound("themes")) { + hashSetOf() + } else { + data.listNotNull<Tag>("themes") + } + + return genres.union(themes).toHashSet() + } + + /** + * The relation is plain text next to the link: + * + `[ sequel of <a href="/encyclopedia/anime.php?id=7809">Clannad (TV)</a> ]` (header) → `sequel of` + * + `<a href="/encyclopedia/anime.php?id=33628">Frieren (TV 2)</a> (sequel)<br>` (list) → `sequel` + */ + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("relatedAnime")) { + return hashSetOf() + } + + return data.listNotNull<String>("relatedAnime").flatMap { html -> + relatedAnimeLinkRegex.findAll(html).map { match -> + val textBefore = html.substring(0, match.range.first).substringAfterLast('>') + val label = if (textBefore.contains('[')) { + textBefore.substringAfterLast('[') + } else { + relationInBracketsRegex.find(match.groups["after"]?.value ?: EMPTY)?.groups?.get("label")?.value + } + RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(match.groups["id"]!!.value), label) + }.toList() + }.toHashSet() + } + + private fun extractDuration(data: ExtractionResult): Duration { + if (data.notFound("duration")) { + return UNKNOWN_DURATION + } + + val value = data.string("duration").normalize().lowercase() + + if (durationMinutesRegex.matches(value)) { + val minutes = durationMinutesRegex.matchEntire(value) + ?.groups + ?.get("minutes") + ?.value + ?.toIntOrNull() + ?: 0 + + return Duration( + value = minutes, + unit = MINUTES, + ) + } + + if (durationHalfHourTextRegex.matches(value)) { + return Duration( + value = 30, + unit = MINUTES, + ) + } + + if (durationOneHourTextRegex.matches(value)) { + return Duration( + value = 1, + unit = HOURS, + ) + } + + if (durationMinSecondsRegex.matches(value)) { + val groups = durationMinSecondsRegex.matchEntire(value) + ?.groups + + val minutes = Duration( + value = groups + ?.get("minutes") + ?.value + ?.toIntOrNull() + ?: 0, + unit = MINUTES, + ) + + val seconds = Duration( + value = groups + ?.get("seconds") + ?.value + ?.toIntOrNull() + ?: 0, + unit = TimeUnit.SECONDS, + ) + + return Duration( + value = minutes.duration + seconds.duration, + unit = TimeUnit.SECONDS, + ) + } + + throw IllegalStateException("Unknown value [$value] for duration.") + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val yearExists = !data.notFound("year") + val yearListAllExists = !data.notFound("year_list_all") + + if (!yearExists && !yearListAllExists) { + return UNKNOWN_STATUS + } + + val year = data.string("year").normalize() + val yearListAll = data.listNotNull<String>("year_list_all").map { it.normalize() } + + val value = when { + yearMonthDayRegex.containsMatchIn(year) -> year + else -> { + yearListAll.filterNot { it.startsWith("Vintage:") }.firstOrNull { it.contains(" to ") } ?: yearListAll.firstOrNull() ?: EMPTY + } + } + + val splitDates = value.split(" to ").map { it.normalize() }.filter { str -> setOf(yearMonthRegex, yearMonthDayRegex).any { it.matches(str) } } + val now = LocalDate.now(clock) + + // Single date + if (splitDates.size == 1) { + val endYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: 0 + val endMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 + val endDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 + + if (endYear == 0) { + return UNKNOWN_STATUS + } + + val endDate = LocalDate.of(endYear, endMonth, endDay) + + return when { + now.isBefore(endDate) -> UPCOMING + now.isAfter(endDate) -> FINISHED + else -> ONGOING + } + } + + val startYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify startYear.") + val startMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 + val startDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 + val startDate = LocalDate.of(startYear, startMonth, startDay) + + val endYear = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify endYear.") + val endMonth = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 + val endDay = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 + val endDate = LocalDate.of(endYear, endMonth, endDay) + + return when { + now.isBefore(startDate) -> UPCOMING + now.isAfter(endDate) -> FINISHED + (now.isEqual(startDate) || now.isAfter(startDate)) && (now.isBefore(endDate) || now.isEqual(endDate)) -> ONGOING + else -> UNKNOWN_STATUS + } + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return if (data.notFound("producers")) { + hashSetOf() + } else { + data.listNotNull<Producer>("producers").toHashSet() + } + } + + public companion object { + /** + * Singleton of [AnimenewsnetworkAnimeConverter] + * @since 6.1.0 + */ + public val instance: AnimenewsnetworkAnimeConverter by lazy { AnimenewsnetworkAnimeConverter() } + + private val unknown_season = AnimeSeason( + year = UNKNOWN_YEAR, + season = UNDEFINED, + ) + + private val relatedAnimeLinkRegex = """<a [^>]*href="[^"]*anime\.php\?id=(?<id>\d+)"[^>]*>[^<]*</a>(?<after>[^<]*)""".toRegex() + private val relationInBracketsRegex = """^\s*\((?<label>[^)]*)\)""".toRegex() + private val yearMonthRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?.*?""".toRegex() + private val yearMonthDayRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?(-(?<day>\d{2}))?.*""".toRegex() + private val new_language_lock_identifiers = hashSetOf(' ', ')') + private val durationMinutesRegex = """^.*?(?<minutes>\d+) minutes ?.*?$""".toRegex() + private val durationHalfHourTextRegex = """^.*?half hour ?.*?$""".toRegex() + private val durationOneHourTextRegex = """^.*?one hour ?.*?$""".toRegex() + private val durationMinSecondsRegex = """^(?<minutes>\d+) +min\. +(?<seconds>\d+)s +minutes( per episode)?$""".toRegex() + } +} \ No newline at end of file diff --git a/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt b/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt index 5028cc0c..0b0082cb 100644 --- a/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt +++ b/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt @@ -1,1922 +1,1923 @@ -package io.github.manamiproject - -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test - -internal class AnimenewsnetworkAnimeConverterTest { - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 6592`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/sources/6592.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=6592")) - } - } - } - - @Nested - inner class TitleTests { - - @Test - fun `title with special chars`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Pa-Pa-Pa the ★ Movie: Perman") - } - } - - @Test - fun `title with type is reduced to title only`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Kaminari Boy Pikkaribee") - } - } - - @Test - fun `title with type and number is reduced to title only`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type_and_number.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Pretty Guardian Sailor Moon Crystal") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `correctly extract 12 episodes from 'number of episodes'`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/12.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(12) - } - } - - @Test - fun `'number of episodes' is missing so take the value from 'episode titles' instead`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/number_of_episodes_missing_use_episodes_titles_instead.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(87) - } - } - - @Test - fun `no indicator for episodes, but 'movie' in title`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes_but_movie_in_title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `no indicator for episodes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isZero() - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `title contains TV in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/tv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `title contains MOVIE in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/movie.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `title contains OVA in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `title contains ONA in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ona.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `title contains SPECIAL in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `title contains motion picture`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/motion-picture-in-title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `title contains ova`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova-in-title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - } - - @Nested - inner class ScoreTests { - - @Test - fun `score exists`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isNotEmpty() - assertThat(result.scores.first().hostname).isEqualTo(AnimenewsnetworkConfig.hostname()) - assertThat(result.scores.first().value).isEqualTo(5.947) - } - } - - @Test - fun `no score`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/no-score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/fit200x200/encyc/A6074-2020173022.1442237283.jpg")) - assertThat(result.picture).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/max500x600/encyc/A6074-2020173022.1442237283.jpg")) - } - } - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `vintage - single date range`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-single-date-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2004) - } - } - - @Test - fun `vintage - year`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1992) - } - } - - @Test - fun `vintage - year-month-day`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `vintage - multiple entries`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-single-date.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2017) - } - } - - @Test - fun `vintage - multiple entries with a range`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1997) - } - } - - @Test - fun `vintage - missing`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-missing.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - - @Test - fun `vintage - year followed by text`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-followed-by-text.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2012) - } - } - } - - @Nested - inner class SeasonTests { - - @ParameterizedTest - @ValueSource(strings = ["01", "02", "03"]) - fun `mapped to WINTER`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["04", "05", "06"]) - fun `mapped to SPRING`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["07", "08", "09"]) - fun `mapped to SUMMER`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["10", "11", "12"]) - fun `mapped to FALL`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `multiple synonyms different languages`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Clannad: After Story", - "Кланнад. Продолжение истории", - "クラナド アフターストーリー", - "클라나드 애프터 스토리", - ) - } - } - - @Test - fun `no synonyms`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/no-synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `synonyms with nested brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/nestesd-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Guerreros Samurai", - "I cinque samurai", - "Les Samouraïs de l'Eternel", - "Los Cinco Samurais", - "Ronin Warriors", - "Samurai Troopers", - "Samurai Warrior", - "Samurai Warriors", - "Yoroiden Samurai Troopers", - "鎧伝サムライトルーパー", - "鎧傳", - "鎧甲聖鬥士", - "魔神坛斗士", - ) - } - } - - @Test - fun `synonyms with multiple meta info brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple-meta-info-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "As aventuras de Tom Sawyer", - "De avonturen van Tom Sawyer", - "Las Aventuras de Tom Sawyer", - "Le avventure di Tom Sawyer", - "Les Aventures de Tom Sawyer", - "Przygody Tomka Sawyera", - "Tom Sawyer", - "Tom Sawyer no Bōken", - "Tom Sawyers Abenteuer", - "Tom Story", - "توم سوير", - "ماجراهای تام سایر", - "トム・ソーヤーの冒険", - "湯姆歷險記", - ) - } - } - - @Test - fun `synonym with brackets in title without language info`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-without-language-info.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Evangelion: 3.0 (-46h) - You Can (Not) Redo", - ) - } - } - - @Test - fun `synonym with brackets in title with language info`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-with-language-info.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Evangelion 2.0: Break", - "Evangelion Shin Gekijōban: Ha", - "Evangelion: 2.0 You Can [Not] Advance", - "Evangelion: 2.22 Tu (No) Puedes Avanzar", - "Evangelion: 2.22 You Can (Not) Advance", - "Evangelion:2.22 Você (Não) Pode Avançar", - "Rebuild of Evangelion 2.0: Division", - "Rebuild of Evangelion: 2.0", - "Евангелион 2.22: ты (не) пройдешь", - "ヱヴァンゲリヲン新劇場版:破", - "福音戰士新劇場版:破", - "에반게리온 신극장판 파", - ) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `neither genres nor themes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/neither-genres-nor-themes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - - @Test - fun `multiple genres and multiple themes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/multiple-genres-and-multiple-themes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "comedy", - "drama", - "romance", - "supernatural", - "alternate future", - "amnesia", - "body switch", - "comets", - "coming-of-age", - "countryside", - "iyashikei", - "mythology", - "natural disaster", - "non-linear narrative", - "school", - "small town", - "time travel", - "tragedy", - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no related anime, but relation in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-but-relation-in-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=7809") - ) - } - } - - @Test - fun `no related anime, no-relation in-brackets, but an adaption`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-no-relation-in-brackets-but-an-adaption.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `related anime`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=33628"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=30277"), - ) - } - } - - @Test - fun `related anime and relation in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime-and-relation-in-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=2960"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=14079"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=12180"), - ) - } - } - - @Test - fun `ignore manga in related anime`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/manga-in-related-anime.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=20000"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=28763"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=23886"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=24366"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=27622"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26353"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26831"), - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=31113"), - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `80 minutes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/80-minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 80, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `24 minutes per episode`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/24-minutes-per-episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 24, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `half hour`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 30, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `half hour per episode`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour-per-episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 30, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `one hour`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 1, - unit = HOURS, - ) - ) - } - } - - @Test - fun `one hour per episode`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour-per-episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 1, - unit = HOURS, - ) - ) - } - } - - @Test - fun `1 min 30s minutes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/1min-30s-minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 90, - unit = SECONDS, - ) - ) - } - } - - @Test - fun `numeric with error`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/numeric-with-error.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 111, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `text with error`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/text-with-error.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 1, - unit = HOURS, - ) - ) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `vintage - no range, year only - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `vintage - no range, year only - ONGOING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1991-01-01T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - no range, year only - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1992-01-01T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - no range, year-month - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2019-11-30T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `vintage - no range, year-month - ONGOING only for first of month`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2019-12-01T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - no range, year-month - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2020-12-02T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - no range, year-month-day - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1993-05-27T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `vintage - no range, year-month-day - ONGOING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1993-05-28T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - no range, year-month-day - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2019-05-29T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - no range, year-month - FINISHED special case where the suffix contains 'to', but not to set a range`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-special-case.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - range - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1991-09-20T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["1991-09-21", "1991-10-21"]) - fun `vintage - range - ONGOING`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - range - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1991-10-22T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `list - range - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1972-09-30T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["1972-10-01", "1974-09-29"]) - fun `list - range - ONGOING`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `list - range - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1974-09-30T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/multiple_studios.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/no_studios.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/multiple_producers.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "ca-cygames anime fund", - "crunchyroll", - "jy animation", - "mixer", - "movic", - "north stars pictures", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/no_producers.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = AnimenewsnetworkAnimeConverter.instance - - // when - val result = AnimenewsnetworkAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnimenewsnetworkAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test + +internal class AnimenewsnetworkAnimeConverterTest { + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 6592`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/sources/6592.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=6592")) + } + } + } + + @Nested + inner class TitleTests { + + @Test + fun `title with special chars`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Pa-Pa-Pa the ★ Movie: Perman") + } + } + + @Test + fun `title with type is reduced to title only`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Kaminari Boy Pikkaribee") + } + } + + @Test + fun `title with type and number is reduced to title only`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type_and_number.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Pretty Guardian Sailor Moon Crystal") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `correctly extract 12 episodes from 'number of episodes'`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/12.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(12) + } + } + + @Test + fun `'number of episodes' is missing so take the value from 'episode titles' instead`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/number_of_episodes_missing_use_episodes_titles_instead.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(87) + } + } + + @Test + fun `no indicator for episodes, but 'movie' in title`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes_but_movie_in_title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `no indicator for episodes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isZero() + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `title contains TV in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/tv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `title contains MOVIE in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/movie.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `title contains OVA in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `title contains ONA in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ona.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `title contains SPECIAL in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `title contains motion picture`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/motion-picture-in-title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `title contains ova`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova-in-title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + } + + @Nested + inner class ScoreTests { + + @Test + fun `score exists`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isNotEmpty() + assertThat(result.scores.first().hostname).isEqualTo(AnimenewsnetworkConfig.hostname()) + assertThat(result.scores.first().value).isEqualTo(5.947) + } + } + + @Test + fun `no score`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/no-score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/fit200x200/encyc/A6074-2020173022.1442237283.jpg")) + assertThat(result.picture).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/max500x600/encyc/A6074-2020173022.1442237283.jpg")) + } + } + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `vintage - single date range`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-single-date-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2004) + } + } + + @Test + fun `vintage - year`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1992) + } + } + + @Test + fun `vintage - year-month-day`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `vintage - multiple entries`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-single-date.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2017) + } + } + + @Test + fun `vintage - multiple entries with a range`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1997) + } + } + + @Test + fun `vintage - missing`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-missing.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + + @Test + fun `vintage - year followed by text`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-followed-by-text.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2012) + } + } + } + + @Nested + inner class SeasonTests { + + @ParameterizedTest + @ValueSource(strings = ["01", "02", "03"]) + fun `mapped to WINTER`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["04", "05", "06"]) + fun `mapped to SPRING`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["07", "08", "09"]) + fun `mapped to SUMMER`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["10", "11", "12"]) + fun `mapped to FALL`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `multiple synonyms different languages`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Clannad: After Story", + "Кланнад. Продолжение истории", + "クラナド アフターストーリー", + "클라나드 애프터 스토리", + ) + } + } + + @Test + fun `no synonyms`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/no-synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `synonyms with nested brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/nestesd-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Guerreros Samurai", + "I cinque samurai", + "Les Samouraïs de l'Eternel", + "Los Cinco Samurais", + "Ronin Warriors", + "Samurai Troopers", + "Samurai Warrior", + "Samurai Warriors", + "Yoroiden Samurai Troopers", + "鎧伝サムライトルーパー", + "鎧傳", + "鎧甲聖鬥士", + "魔神坛斗士", + ) + } + } + + @Test + fun `synonyms with multiple meta info brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple-meta-info-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "As aventuras de Tom Sawyer", + "De avonturen van Tom Sawyer", + "Las Aventuras de Tom Sawyer", + "Le avventure di Tom Sawyer", + "Les Aventures de Tom Sawyer", + "Przygody Tomka Sawyera", + "Tom Sawyer", + "Tom Sawyer no Bōken", + "Tom Sawyers Abenteuer", + "Tom Story", + "توم سوير", + "ماجراهای تام سایر", + "トム・ソーヤーの冒険", + "湯姆歷險記", + ) + } + } + + @Test + fun `synonym with brackets in title without language info`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-without-language-info.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Evangelion: 3.0 (-46h) - You Can (Not) Redo", + ) + } + } + + @Test + fun `synonym with brackets in title with language info`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-with-language-info.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Evangelion 2.0: Break", + "Evangelion Shin Gekijōban: Ha", + "Evangelion: 2.0 You Can [Not] Advance", + "Evangelion: 2.22 Tu (No) Puedes Avanzar", + "Evangelion: 2.22 You Can (Not) Advance", + "Evangelion:2.22 Você (Não) Pode Avançar", + "Rebuild of Evangelion 2.0: Division", + "Rebuild of Evangelion: 2.0", + "Евангелион 2.22: ты (не) пройдешь", + "ヱヴァンゲリヲン新劇場版:破", + "福音戰士新劇場版:破", + "에반게리온 신극장판 파", + ) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `neither genres nor themes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/neither-genres-nor-themes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + + @Test + fun `multiple genres and multiple themes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/multiple-genres-and-multiple-themes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "comedy", + "drama", + "romance", + "supernatural", + "alternate future", + "amnesia", + "body switch", + "comets", + "coming-of-age", + "countryside", + "iyashikei", + "mythology", + "natural disaster", + "non-linear narrative", + "school", + "small town", + "time travel", + "tragedy", + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no related anime, but relation in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-but-relation-in-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=7809"), "sequel of") + ) + } + } + + @Test + fun `no related anime, no-relation in-brackets, but an adaption`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-no-relation-in-brackets-but-an-adaption.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `related anime`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=33628"), "sequel"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=30277"), "spinoff"), + ) + } + } + + @Test + fun `related anime and relation in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime-and-relation-in-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=2960"), "alternate retelling of"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=14079"), "side story"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=12180"), "side story"), + ) + } + } + + @Test + fun `ignore manga in related anime`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/manga-in-related-anime.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=20000"), "spinoff of"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=28763")), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=23886")), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=24366"), "sequel"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=27622")), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26353"), "adaptation"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26831"), "side story"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=31113"), "sequel"), + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `80 minutes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/80-minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 80, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `24 minutes per episode`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/24-minutes-per-episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 24, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `half hour`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 30, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `half hour per episode`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour-per-episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 30, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `one hour`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 1, + unit = HOURS, + ) + ) + } + } + + @Test + fun `one hour per episode`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour-per-episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 1, + unit = HOURS, + ) + ) + } + } + + @Test + fun `1 min 30s minutes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/1min-30s-minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 90, + unit = SECONDS, + ) + ) + } + } + + @Test + fun `numeric with error`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/numeric-with-error.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 111, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `text with error`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/text-with-error.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 1, + unit = HOURS, + ) + ) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `vintage - no range, year only - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `vintage - no range, year only - ONGOING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1991-01-01T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - no range, year only - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1992-01-01T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - no range, year-month - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2019-11-30T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `vintage - no range, year-month - ONGOING only for first of month`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2019-12-01T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - no range, year-month - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2020-12-02T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - no range, year-month-day - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1993-05-27T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `vintage - no range, year-month-day - ONGOING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1993-05-28T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - no range, year-month-day - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2019-05-29T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - no range, year-month - FINISHED special case where the suffix contains 'to', but not to set a range`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-special-case.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - range - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1991-09-20T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["1991-09-21", "1991-10-21"]) + fun `vintage - range - ONGOING`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - range - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1991-10-22T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `list - range - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1972-09-30T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["1972-10-01", "1974-09-29"]) + fun `list - range - ONGOING`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `list - range - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1974-09-30T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/multiple_studios.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/no_studios.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/multiple_producers.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "ca-cygames anime fund", + "crunchyroll", + "jy animation", + "mixer", + "movic", + "north stars pictures", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/no_producers.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = AnimenewsnetworkAnimeConverter.instance + + // when + val result = AnimenewsnetworkAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnimenewsnetworkAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt b/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt index a6f1afd5..0bfa235f 100644 --- a/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt +++ b/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt @@ -1,322 +1,329 @@ -package io.github.manamiproject.modb.anisearch - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.* -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import kotlinx.coroutines.withContext -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property xmlExtractor Extractor which retrieves the data from raw data. - * @property jsonExtractor Extractor which retrieves the data from raw data. - * @property relationsDir Directory containing the raw files for the related anime. - * @throws IllegalArgumentException if [relationsDir] doesn't exist or is not a directory. - */ -public class AnisearchAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnisearchConfig, - private val xmlExtractor: DataExtractor = XmlDataExtractor, - private val jsonExtractor: DataExtractor = JsonDataExtractor, - private val relationsDir: Directory, -) : AnimeConverter { - - init { - require(relationsDir.directoryExists()) { "Directory for relations [$relationsDir] does not exist or is not a directory." } - } - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = xmlExtractor.extract(rawContent, mapOf( - "jsonld" to "//script[@type='application/ld+json']/node()", - "image" to "//meta[@property='og:image']/@content", - "title" to "//meta[@property='og:title']/@content", - "type" to "//ul[@class='xlist row simple infoblock']//div[@class='type']", - "status" to "//div[@class='status']", - "duration" to "//ul[@class='xlist row simple infoblock']//time", - "tags" to "//section[@id='genres-tags']//ul[@class='cloud']//li//a/text()", - "source" to "//div[@id='content-outer']/@data-id", - "synonymsByLanguage" to "//div[@class='title']//strong/text()", - "synonymsBySubheader" to "//div[@class='title']//div[@class='grey']/text()", - "synonymsDivNoSpan" to "//div[@class='synonyms']", - "synonymsDivSpan" to "//div[@class='synonyms']//span[@id='text-synonyms']", - "synonymsItalic" to "//div[@class='synonyms']//i/text()", - "score" to "//td[text()='Calculated Value']/text()", - "studios" to "//div[@class='company']/span[text()='Studio:']/following-sibling::*/text()", - )) - - val jsonld = data.listNotNull<String>("jsonld").first() - val jsonData = jsonExtractor.extract(jsonld, mapOf( - "title" to "$.name", - "source" to "$.url", - "image" to "$.image", - "episodes" to "$.numberOfEpisodes", // they mess up the type. They use both strings and integer for episodes - "year" to "startDate", - "ratingValue" to "$.aggregateRating.ratingValue", - "worstRating" to "$.aggregateRating.worstRating", - "bestRating" to "$.aggregateRating.bestRating", - "ratingCount" to ".aggregateRating.ratingCount", - )) - - val picture = extractPicture(data) - - return@withContext AnimeRaw( - _title = extractTitle(jsonData, data), - episodes = extractEpisodes(jsonData), - type = extractType(data), - picture = picture, - thumbnail = generateThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(jsonData), - _sources = extractSourcesEntry(data), - _synonyms = extractSynonyms(data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = hashSetOf(), // not available on anisearch - ).addScores(extractScore(jsonData, data)) - } - - private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { - return jsonldData.stringOrDefault("title").ifBlank { data.stringOrDefault("title") } - .trim() - .remove(" (Anime)") - .trim() - } - - private fun extractEpisodes(jsonldData: ExtractionResult): Episodes = jsonldData.intOrDefault("episodes", 1) - - private fun extractType(data: ExtractionResult): AnimeType { - val type = data.string("type") - .split(',') - .first() - .trim() - .trimStart('[') - .lowercase() - - return when(type) { - "bonus" -> SPECIAL - "cm" -> SPECIAL - "movie" -> MOVIE - "music video" -> SPECIAL - "other" -> UNKNOWN_TYPE - "ova" -> OVA - "tv-series" -> TV - "tv-special" -> TV - "unknown" -> UNKNOWN_TYPE - "web" -> ONA - else -> throw IllegalStateException("Unmapped type [$type]") - } - } - - private fun extractPicture(data: ExtractionResult): URI { - // links in JSON are invalid (http 404) for a few weeks now. Have to solely rely on meta tag again - if (data.notFound("image")) { - return NO_PICTURE_THUMBNAIL - } - - val url = data.string("image") - - return when (url) { - DEFAULT_IMAGE -> NO_PICTURE - else -> URI(data.string("image").trim()) - } - } - - private fun generateThumbnail(picture: URI): URI { - if (picture == NO_PICTURE) { - return NO_PICTURE_THUMBNAIL - } - - val value = picture.toString() - .replace("_600.webp", "_300.webp") - return URI(value) - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val value = if (data.notFound("status")) { - EMPTY - } else { - data.listNotNull<String>("status").first() - } - - return when(value.trim().lowercase()) { - "aborted", "canceled" -> UNKNOWN_STATUS - "completed" -> FINISHED - "ongoing" -> ONGOING - "upcoming" -> UPCOMING - "on hold", EMPTY -> UNKNOWN_STATUS - else -> throw IllegalStateException("Unmapped status [$value]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val textValue = data.stringOrDefault("duration", "0") - .trim() - .lowercase() - - val value = """\d+""".toRegex().find(textValue)!!.value.toInt() - val fallbackUnit = "unknown" - val extractedUnit = """[aA-zZ]+""".toRegex().find(textValue)?.value ?: fallbackUnit - - when { - value > 0 && extractedUnit == fallbackUnit -> throw IllegalStateException("Value for duration is present [], but unit is unknown") - value == 0 && extractedUnit == fallbackUnit -> return Duration.UNKNOWN - } - - val unit = when(extractedUnit) { - "hrs" -> HOURS - "min" -> MINUTES - else -> throw IllegalStateException("Unmapped duration unit [$extractedUnit]") - } - - return Duration(value, unit) - } - - private fun extractAnimeSeason(jsonldData: ExtractionResult): AnimeSeason { - val date = jsonldData.stringOrDefault("year") - if (date.eitherNullOrBlank()) { - return AnimeSeason() - } - - val year = """\d{4}""".toRegex().find(date)!!.value.toInt() - val fallback = "0" - val month = ("""-\d{2}-""".toRegex().find(date)?.value?.replace("""-""".toRegex(), EMPTY)?.ifBlank { fallback } ?: fallback).toInt() - - val season = when(month) { - 1, 2, 3 -> WINTER - 4, 5, 6 -> SPRING - 7, 8, 9 -> SUMMER - 10, 11, 12 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - year = year, - season = season - ) - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("source"))) - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - val synonyms = if (data.notFound("synonymsByLanguage")) { - hashSetOf() - } else { - data.listNotNull<Title>("synonymsByLanguage").toHashSet() - } - - if (!data.notFound("synonymsBySubheader")) { - data.listNotNull<Title>("synonymsBySubheader").forEach { synonyms.add(it) } - } - - if (!data.notFound("synonymsDivNoSpan")) { - data.listNotNull<Title>("synonymsDivNoSpan") - .forEach { synonyms.add(it) } - } - - if (!data.notFound("synonymsDivSpan")) { - data.listNotNull<Title>("synonymsDivSpan") - .forEach { synonyms.add(it) } - } - - if (!data.notFound("synonymsItalic")) { - data.listNotNull<Title>("synonymsItalic") - .forEach { synonyms.add(it) } - } - - return synonyms.map { it.trimStart(',').trimEnd(',') }.toHashSet() - } - - private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> = withContext(LIMITED_CPU) { - val id = data.string("source") - val relationsFile = relationsDir.resolve("$id.${metaDataProviderConfig.fileSuffix()}") - - check(relationsFile.regularFileExists()) { "Relations file is missing for [$id]." } - - val relatedAnimeData = xmlExtractor.extract(relationsFile.readFile(), mapOf( - "relatedAnime" to "//section[@id='relations_anime']//table//tbody//a/@href", - )) - - if (relatedAnimeData.notFound("relatedAnime")) { - hashSetOf() - } else { - relatedAnimeData.listNotNull<String>("relatedAnime") - .map { it.remove("anime/") } - .map { it.substring(0, it.indexOf(',')) } - .map { metaDataProviderConfig.buildAnimeLink(it) } - .toHashSet() - } - } - - private fun extractScore(jsonData: ExtractionResult, data: ExtractionResult): MetaDataProviderScore { - val from = jsonData.doubleOrDefault("worstRating", 0.1) - val to = jsonData.doubleOrDefault("bestRating", 5.0) - val rawScore = when { - !jsonData.notFound("ratingValue") -> { - // if there is effectively no rating they set the rating to 2.5 (more or less the middle of the range and set count to 1 - jsonData.doubleOrDefault("ratingValue") - .takeUnless { jsonData.int("ratingCount") == 1 && jsonData.doubleOrDefault("ratingValue") == 2.5 } - ?: 0.0 - } - !data.notFound("score") && jsonData.notFound("ratingValue") -> { - data.string("score") - .remove("Calculated Value") - .substringBefore('=') - .trim() - .toDoubleOrNull() ?: 0.0 - } - else -> 0.0 - } - - return if (rawScore == 0.0) { - NoMetaDataProviderScore - } else { - MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = from..to, - ) - } - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - return if (data.notFound("tags")) { - hashSetOf() - } else { - data.listNotNull<Tag>("tags").toHashSet() - } - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private companion object { - private const val DEFAULT_IMAGE = "https://cdn.anisearch.com/images/anime/cover/0_600.webp" - private const val DEFAULT_THUMBNAIL = "https://cdn.anisearch.com/images/anime/cover/0_300.webp" - } -} \ No newline at end of file +package io.github.manamiproject.modb.anisearch + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.* +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.WRAP_TABLE_ROW +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import kotlinx.coroutines.withContext +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property xmlExtractor Extractor which retrieves the data from raw data. + * @property jsonExtractor Extractor which retrieves the data from raw data. + * @property relationsDir Directory containing the raw files for the related anime. + * @throws IllegalArgumentException if [relationsDir] doesn't exist or is not a directory. + */ +public class AnisearchAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnisearchConfig, + private val xmlExtractor: DataExtractor = XmlDataExtractor, + private val jsonExtractor: DataExtractor = JsonDataExtractor, + private val relationsDir: Directory, +) : AnimeConverter { + + init { + require(relationsDir.directoryExists()) { "Directory for relations [$relationsDir] does not exist or is not a directory." } + } + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = xmlExtractor.extract(rawContent, mapOf( + "jsonld" to "//script[@type='application/ld+json']/node()", + "image" to "//meta[@property='og:image']/@content", + "title" to "//meta[@property='og:title']/@content", + "type" to "//ul[@class='xlist row simple infoblock']//div[@class='type']", + "status" to "//div[@class='status']", + "duration" to "//ul[@class='xlist row simple infoblock']//time", + "tags" to "//section[@id='genres-tags']//ul[@class='cloud']//li//a/text()", + "source" to "//div[@id='content-outer']/@data-id", + "synonymsByLanguage" to "//div[@class='title']//strong/text()", + "synonymsBySubheader" to "//div[@class='title']//div[@class='grey']/text()", + "synonymsDivNoSpan" to "//div[@class='synonyms']", + "synonymsDivSpan" to "//div[@class='synonyms']//span[@id='text-synonyms']", + "synonymsItalic" to "//div[@class='synonyms']//i/text()", + "score" to "//td[text()='Calculated Value']/text()", + "studios" to "//div[@class='company']/span[text()='Studio:']/following-sibling::*/text()", + )) + + val jsonld = data.listNotNull<String>("jsonld").first() + val jsonData = jsonExtractor.extract(jsonld, mapOf( + "title" to "$.name", + "source" to "$.url", + "image" to "$.image", + "episodes" to "$.numberOfEpisodes", // they mess up the type. They use both strings and integer for episodes + "year" to "startDate", + "ratingValue" to "$.aggregateRating.ratingValue", + "worstRating" to "$.aggregateRating.worstRating", + "bestRating" to "$.aggregateRating.bestRating", + "ratingCount" to ".aggregateRating.ratingCount", + )) + + val picture = extractPicture(data) + + return@withContext AnimeRaw( + _title = extractTitle(jsonData, data), + episodes = extractEpisodes(jsonData), + type = extractType(data), + picture = picture, + thumbnail = generateThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(jsonData), + _sources = extractSourcesEntry(data), + _synonyms = extractSynonyms(data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = hashSetOf(), // not available on anisearch + ).addScores(extractScore(jsonData, data)) + } + + private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { + return jsonldData.stringOrDefault("title").ifBlank { data.stringOrDefault("title") } + .trim() + .remove(" (Anime)") + .trim() + } + + private fun extractEpisodes(jsonldData: ExtractionResult): Episodes = jsonldData.intOrDefault("episodes", 1) + + private fun extractType(data: ExtractionResult): AnimeType { + val type = data.string("type") + .split(',') + .first() + .trim() + .trimStart('[') + .lowercase() + + return when(type) { + "bonus" -> SPECIAL + "cm" -> SPECIAL + "movie" -> MOVIE + "music video" -> SPECIAL + "other" -> UNKNOWN_TYPE + "ova" -> OVA + "tv-series" -> TV + "tv-special" -> TV + "unknown" -> UNKNOWN_TYPE + "web" -> ONA + else -> throw IllegalStateException("Unmapped type [$type]") + } + } + + private fun extractPicture(data: ExtractionResult): URI { + // links in JSON are invalid (http 404) for a few weeks now. Have to solely rely on meta tag again + if (data.notFound("image")) { + return NO_PICTURE_THUMBNAIL + } + + val url = data.string("image") + + return when (url) { + DEFAULT_IMAGE -> NO_PICTURE + else -> URI(data.string("image").trim()) + } + } + + private fun generateThumbnail(picture: URI): URI { + if (picture == NO_PICTURE) { + return NO_PICTURE_THUMBNAIL + } + + val value = picture.toString() + .replace("_600.webp", "_300.webp") + return URI(value) + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val value = if (data.notFound("status")) { + EMPTY + } else { + data.listNotNull<String>("status").first() + } + + return when(value.trim().lowercase()) { + "aborted", "canceled" -> UNKNOWN_STATUS + "completed" -> FINISHED + "ongoing" -> ONGOING + "upcoming" -> UPCOMING + "on hold", EMPTY -> UNKNOWN_STATUS + else -> throw IllegalStateException("Unmapped status [$value]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val textValue = data.stringOrDefault("duration", "0") + .trim() + .lowercase() + + val value = """\d+""".toRegex().find(textValue)!!.value.toInt() + val fallbackUnit = "unknown" + val extractedUnit = """[aA-zZ]+""".toRegex().find(textValue)?.value ?: fallbackUnit + + when { + value > 0 && extractedUnit == fallbackUnit -> throw IllegalStateException("Value for duration is present [], but unit is unknown") + value == 0 && extractedUnit == fallbackUnit -> return Duration.UNKNOWN + } + + val unit = when(extractedUnit) { + "hrs" -> HOURS + "min" -> MINUTES + else -> throw IllegalStateException("Unmapped duration unit [$extractedUnit]") + } + + return Duration(value, unit) + } + + private fun extractAnimeSeason(jsonldData: ExtractionResult): AnimeSeason { + val date = jsonldData.stringOrDefault("year") + if (date.eitherNullOrBlank()) { + return AnimeSeason() + } + + val year = """\d{4}""".toRegex().find(date)!!.value.toInt() + val fallback = "0" + val month = ("""-\d{2}-""".toRegex().find(date)?.value?.replace("""-""".toRegex(), EMPTY)?.ifBlank { fallback } ?: fallback).toInt() + + val season = when(month) { + 1, 2, 3 -> WINTER + 4, 5, 6 -> SPRING + 7, 8, 9 -> SUMMER + 10, 11, 12 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + year = year, + season = season + ) + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("source"))) + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + val synonyms = if (data.notFound("synonymsByLanguage")) { + hashSetOf() + } else { + data.listNotNull<Title>("synonymsByLanguage").toHashSet() + } + + if (!data.notFound("synonymsBySubheader")) { + data.listNotNull<Title>("synonymsBySubheader").forEach { synonyms.add(it) } + } + + if (!data.notFound("synonymsDivNoSpan")) { + data.listNotNull<Title>("synonymsDivNoSpan") + .forEach { synonyms.add(it) } + } + + if (!data.notFound("synonymsDivSpan")) { + data.listNotNull<Title>("synonymsDivSpan") + .forEach { synonyms.add(it) } + } + + if (!data.notFound("synonymsItalic")) { + data.listNotNull<Title>("synonymsItalic") + .forEach { synonyms.add(it) } + } + + return synonyms.map { it.trimStart(',').trimEnd(',') }.toHashSet() + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> = withContext(LIMITED_CPU) { + val id = data.string("source") + val relationsFile = relationsDir.resolve("$id.${metaDataProviderConfig.fileSuffix()}") + + check(relationsFile.regularFileExists()) { "Relations file is missing for [$id]." } + + val relatedAnimeData = xmlExtractor.extract(relationsFile.readFile(), mapOf( + "relatedAnime" to "//section[@id='relations_anime']//table//tbody//tr/html()", + )) + + if (relatedAnimeData.notFound("relatedAnime")) { + hashSetOf() + } else { + // <tr><th><a href="anime/2753,ghost-in-the-shell-2-innocence">...</a><br><span ...>Sequel</span></th>...</tr> + xmlExtractor.extractLabelledLinks( + snippets = relatedAnimeData.listNotNull<String>("relatedAnime"), + labelSelector = "//th//span/text()", + linkSelector = "//a/@href", + wrap = WRAP_TABLE_ROW, + ).flatMap { entry -> entry.links.map { it to entry.label } } + .map { (link, label) -> link.remove("anime/") to label?.takeUnless { it.trim() == "?" } } // "?" = relation unknown + .map { (link, label) -> RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(link.substring(0, link.indexOf(','))), label) } + .toHashSet() + } + } + + private fun extractScore(jsonData: ExtractionResult, data: ExtractionResult): MetaDataProviderScore { + val from = jsonData.doubleOrDefault("worstRating", 0.1) + val to = jsonData.doubleOrDefault("bestRating", 5.0) + val rawScore = when { + !jsonData.notFound("ratingValue") -> { + // if there is effectively no rating they set the rating to 2.5 (more or less the middle of the range and set count to 1 + jsonData.doubleOrDefault("ratingValue") + .takeUnless { jsonData.int("ratingCount") == 1 && jsonData.doubleOrDefault("ratingValue") == 2.5 } + ?: 0.0 + } + !data.notFound("score") && jsonData.notFound("ratingValue") -> { + data.string("score") + .remove("Calculated Value") + .substringBefore('=') + .trim() + .toDoubleOrNull() ?: 0.0 + } + else -> 0.0 + } + + return if (rawScore == 0.0) { + NoMetaDataProviderScore + } else { + MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = from..to, + ) + } + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + return if (data.notFound("tags")) { + hashSetOf() + } else { + data.listNotNull<Tag>("tags").toHashSet() + } + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private companion object { + private const val DEFAULT_IMAGE = "https://cdn.anisearch.com/images/anime/cover/0_600.webp" + private const val DEFAULT_THUMBNAIL = "https://cdn.anisearch.com/images/anime/cover/0_300.webp" + } +} \ No newline at end of file diff --git a/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt b/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt index ae63f8d9..46af769c 100644 --- a/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt +++ b/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt @@ -1,2023 +1,2024 @@ -package io.github.manamiproject.modb.anisearch - -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import io.github.manamiproject.modb.test.testResource -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import java.net.URI -import kotlin.io.path.copyTo -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - - -internal class AnisearchAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/special_chars.html") - "<html></html>".writeToFile(tempDir.resolve("15159.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("22/7: 8+3=?") - } - } - - @Test - fun `no suffix for title`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/title_not_set_in_jsonld.html") - "<html></html>".writeToFile(tempDir.resolve("4410.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo(".hack//G.U. Returner") - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(NO_PICTURE) - assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) - } - } - - @Test - fun `picture and thumbnail`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_600.webp")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_300.webp")) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 3633`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/sources/3633.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://anisearch.com/anime/3633")) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `'bonus' is mapped to SPECIAL`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/bonus.html") - "<html></html>".writeToFile(tempDir.resolve("10454.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'cm' is mapped to 'SPECIAL'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/cm.html") - "<html></html>".writeToFile(tempDir.resolve("12290.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'movie' is mapped to Movie`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/movie.html") - "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `'music video' is mapped to SPECIAL`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/music-video.html") - "<html></html>".writeToFile(tempDir.resolve("9830.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `other' is mapped to UNKNOWN`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/other.html") - "<html></html>".writeToFile(tempDir.resolve("16289.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `'ova' is mapped to OVA`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/ova.html") - "<html></html>".writeToFile(tempDir.resolve("3627.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `tv-series' is mapped to TV`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-series.html") - "<html></html>".writeToFile(tempDir.resolve("4946.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `tv-special' is mapped to TV`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-special.html") - "<html></html>".writeToFile(tempDir.resolve("13250.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `unknown' is mapped to UNKNOWN`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/unknown.html") - "<html></html>".writeToFile(tempDir.resolve("17467.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `web' is mapped to ONA`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/web.html") - "<html></html>".writeToFile(tempDir.resolve("14935.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple tags`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/multiple_tags.html") - "<html></html>".writeToFile(tempDir.resolve("15073.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "action comedy", - "adventure", - "alternative world", - "comedy", - "demon", - "ecchi", - "fantasy", - "guns", - "harem", - "knight", - "magic", - "parody", - "robots & android", - "science-fiction", - "swords & co", - ) - } - } - - @Test - fun `extract exactly one tag`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/one_tag.html") - "<html></html>".writeToFile(tempDir.resolve("613.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly("drama") - } - } - - @Test - fun `no tags available`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/no_tags.html") - "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes defaults to 1`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/unknown.html") - "<html></html>".writeToFile(tempDir.resolve("16578.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `1 episode`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1.html") - "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/10.html") - "<html></html>".writeToFile(tempDir.resolve("3135.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/100.html") - "<html></html>".writeToFile(tempDir.resolve("4138.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1818.html") - "<html></html>".writeToFile(tempDir.resolve("5801.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - - @Test - fun `although not a double in json the number of episodes is deserialized to kotlin type double`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/type-is-double.html") - "<html></html>".writeToFile(tempDir.resolve("16804.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(6) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hour`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_hour.html") - "<html></html>".writeToFile(tempDir.resolve("6247.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 minute`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_minute.html") - "<html></html>".writeToFile(tempDir.resolve("11051.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `2 hours`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/2_hours.html") - "<html></html>".writeToFile(tempDir.resolve("6889.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `24 minutes per episode`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/24_minutes_per_episode.html") - "<html></html>".writeToFile(tempDir.resolve("14844.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) - } - } - - @Test - fun `63 minutes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/63_minutes_by_6_episodes.html") - "<html></html>".writeToFile(tempDir.resolve("7192.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(63, MINUTES)) - } - } - - @Test - fun `70 minutes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/70_minutes.html") - "<html></html>".writeToFile(tempDir.resolve("7163.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(70, MINUTES)) - } - } - - @Test - fun `134 minutes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/134_minutes.html") - "<html></html>".writeToFile(tempDir.resolve("602.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(134, MINUTES)) - } - } - - @Test - fun `episodes and duration unknown`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_and_duration_unknown.html") - "<html></html>".writeToFile(tempDir.resolve("16711.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `episodes known duration unknown`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_known_duration_unknown.html") - "<html></html>".writeToFile(tempDir.resolve("17039.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `multiple synonyms`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/multiple_synonyms.html") - "<html></html>".writeToFile(tempDir.resolve("1958.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Bésame Licia", - "Embrasse-moi Lucile", - "Kiss Me Licia", - "Love Me Night", - "Love in Rock 'n Roll", - "Lucile, Amour et Rock'n'Roll", - "Rock'n' Roll Kids", - "愛してナイト", - ) - } - } - - @Test - fun `no synonyms`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/no_synonyms.html") - "<html></html>".writeToFile(tempDir.resolve("16260.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `single synonym`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/single_synonym.html") - "<html></html>".writeToFile(tempDir.resolve("14456.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly( - "センコロール 3", - ) - } - } - - @Test - fun `single synonym and romanji alteration`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/romanji_alteration.html") - "<html></html>".writeToFile(tempDir.resolve("13631.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Chikyūgai Shōnen Shōjo", - "Extra-Terrestrial Boys & Girls", - "Extraterrestial Boys and Girls", - "Jóvenes en órbita", - "Notre jeunesse en orbite", - "The Orbital Children", - "地球外少年少女", - ) - } - } - - @Test - fun `correctly separate hidden synonyms using 11197 as an example`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_11197.html") - "<html></html>".writeToFile(tempDir.resolve("11197.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "DanMachi OVA", - "Danmachi OAV", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", - "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", - "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", - ) - setOf( - "Danmachi OAV", - "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darou ka", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "DanMachi OVA", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", - "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", - ) - } - } - - @Test - fun `correctly separate hidden synonyms using 8724 as an example`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_8724.html") - "<html></html>".writeToFile(tempDir.resolve("8724.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "My Teen Romantic Comedy SNAFU OVA", - "My Youth Romantic Comedy Is Wrong as I Expected. OVA", - "Oregairu OVA", - "Yahari Ore no Seishun Love Come wa Machigatte Iru. OAD", - "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto o Negawazaru o Enai.", - "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", - "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", - "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ōkaran Koto o Negawazaru o Enai.", - "やはり俺の青春ラブコメはまちがっている。「こちらとしても彼ら彼女らの行く末に幸多からんことを願わざるを得ない。」", - ) - } - } - - @Test - fun `synonyms containing named parts are not split`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/synonyms_contain_named_parts.html") - "<html></html>".writeToFile(tempDir.resolve("15599.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Extra One Room: Second Season: Hanasaka Yui Experiments / Hanasaka Yui Gets Delivered / Nanahashi Minori Holds a Meeting / Nanahashi Minori Becomes a Big-Shot / Amatsuki Mashiro Becomes a Cat / Amatsuki Mashiro Gives Treatment", - "Extra One Room: Second Season: Hanasaka Yui wa Tameshite Miru / Hanasaka Yui wa Okurarete Kuru / Nanahashi Minori wa Kaigi Suru / Nanahashi Minori wa Oomono ni Naru / Amatsuki Mashiro wa Neko ni Naru / Amatsuki Mashiro wa Chiryou Suru", - "One Room 2nd Season Extra", - "One Room Second Season Special", - "One Room セカンドシーズン -extra-", - ) - } - } - - @Test - fun `hidden synonyms and named parts`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_and_named_parts.html") - "<html></html>".writeToFile(tempDir.resolve("8093.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Youko x Boku SS Special", - "Inu x Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", - "Inu x Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", - "Inu × Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", - "Inu x Boku SS Special", - "妖狐×僕SS 御狐神くん変化 / スイッチ / おままごと", - "Youko × Boku SS Special", - "Inu × Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", - "Inu x Boku SS: Miketsukami-kun Henka / Switch / Omamagoto", - "Inu × Boku SS Special", - "Inu × Boku Secret Service: Miketsukami’s Metamorphosis", - ) - } - } - - @Test - fun `correctly extract synonyms in italic`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/italic.html") - "<html></html>".writeToFile(tempDir.resolve("17015.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly( - "Bakugan: Battle Planet Season 4", - "Bakugan: Battle Planet Staffel 4", - "爆丸エボリューションズ", - ) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'aborted' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/aborted.html") - "<html></html>".writeToFile(tempDir.resolve("12433.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `'canceled' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/canceled.html") - "<html></html>".writeToFile(tempDir.resolve("13270.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `'on hold' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/on_hold.html") - "<html></html>".writeToFile(tempDir.resolve("16925.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `no status is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/no_status.html") - "<html></html>".writeToFile(tempDir.resolve("14494.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `'completed' is mapped to 'FINISHED'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `completed is only based on the original release`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed_in_japan_upcoming_elsewhere.html") - "<html></html>".writeToFile(tempDir.resolve("6222.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `'ongoing' is mapped to ONGOING`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/ongoing.html") - "<html></html>".writeToFile(tempDir.resolve("1721.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'upcoming' is mapped to UPCOMING`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/upcoming.html") - "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @ParameterizedTest - @CsvSource(value = [ - "01, 14628", - "02, 14967", - "03, 16251", - ]) - fun `'jan', 'feb', and 'mar' are mapped to WINTER`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "04, 14508", - "05, 15264", - "06, 14935", - ]) - fun `'apr', 'may' and 'jul' are mapped to SPRING`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "07, 14816", - "08, 16401", - "09, 16401", - ]) - fun `'jul', 'aug' and 'sep' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "10, 15606", - "11, 15042", - "12, 14484", - ]) - fun `'oct', 'nov' and 'dec' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "year_only, 17467", - "unknown, 16275", - ]) - fun `if the month cannot be determined use UNDEFINED`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `2021-08-06`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/2021-08-06.html") - "<html></html>".writeToFile(tempDir.resolve("15890.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2021) - } - } - - @Test - fun `1958-11`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1958-11.html") - "<html></html>".writeToFile(tempDir.resolve("5976.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1958) - } - } - - @Test - fun `1991`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1991.html") - "<html></html>".writeToFile(tempDir.resolve("168.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1991) - } - } - - @Test - fun `unknown year`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/unknown.html") - "<html></html>".writeToFile(tempDir.resolve("16275.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no related anime but a single adaption`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption.html") - .copyTo(tempDir.resolve("14844.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no related anime`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime.html") - .copyTo(tempDir.resolve("10941.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `single related anime`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/single_related_anime_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/single_related_anime.html") - .copyTo(tempDir.resolve("16777.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).containsExactly( - URI("https://anisearch.com/anime/17530") - ) - } - } - - @Test - fun `multiple related anime`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/multiple_related_anime_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/multiple_related_anime.html") - .copyTo(tempDir.resolve("4942.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).containsExactly( - URI("https://anisearch.com/anime/2088"), - URI("https://anisearch.com/anime/2753"), - ) - } - } - - @Test - fun `throws exception if relations file is missing`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/related_anime_file_missing_main.html") - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = exceptionExpected<IllegalStateException> { - converter.convert(testFile) - } - - // then - assertThat(result).hasMessage("Relations file is missing for [4942].") - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/score.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).hasSize(1) - assertThat((result.scores.first()).scaledValue()).isEqualTo(9.081632653061224) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/no-score.html") - "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/multiple_studios.html") - "<html></html>".writeToFile(tempDir.resolve("1945.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "pierrot co., ltd.", - "studio deen co., ltd.", - ) - } - } - - @Test - fun `no studios`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/no_studios.html") - "<html></html>".writeToFile(tempDir.resolve("5419.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ConstructorTests { - - @Test - fun `throws exception if relationsDir doesn't exist`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - // when - val result = exceptionExpected<IllegalArgumentException> { - AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir.resolve("relations"), - ) - } - - // then - assertThat(result).hasMessage("Directory for relations [${tempDir.resolve("relations")}] does not exist or is not a directory.") - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anisearch + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import io.github.manamiproject.modb.test.testResource +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import java.net.URI +import kotlin.io.path.copyTo +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + + +internal class AnisearchAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/special_chars.html") + "<html></html>".writeToFile(tempDir.resolve("15159.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("22/7: 8+3=?") + } + } + + @Test + fun `no suffix for title`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/title_not_set_in_jsonld.html") + "<html></html>".writeToFile(tempDir.resolve("4410.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo(".hack//G.U. Returner") + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(NO_PICTURE) + assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) + } + } + + @Test + fun `picture and thumbnail`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_600.webp")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_300.webp")) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 3633`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/sources/3633.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://anisearch.com/anime/3633")) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `'bonus' is mapped to SPECIAL`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/bonus.html") + "<html></html>".writeToFile(tempDir.resolve("10454.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'cm' is mapped to 'SPECIAL'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/cm.html") + "<html></html>".writeToFile(tempDir.resolve("12290.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'movie' is mapped to Movie`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/movie.html") + "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `'music video' is mapped to SPECIAL`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/music-video.html") + "<html></html>".writeToFile(tempDir.resolve("9830.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `other' is mapped to UNKNOWN`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/other.html") + "<html></html>".writeToFile(tempDir.resolve("16289.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `'ova' is mapped to OVA`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/ova.html") + "<html></html>".writeToFile(tempDir.resolve("3627.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `tv-series' is mapped to TV`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-series.html") + "<html></html>".writeToFile(tempDir.resolve("4946.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `tv-special' is mapped to TV`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-special.html") + "<html></html>".writeToFile(tempDir.resolve("13250.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `unknown' is mapped to UNKNOWN`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/unknown.html") + "<html></html>".writeToFile(tempDir.resolve("17467.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `web' is mapped to ONA`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/web.html") + "<html></html>".writeToFile(tempDir.resolve("14935.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple tags`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/multiple_tags.html") + "<html></html>".writeToFile(tempDir.resolve("15073.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "action comedy", + "adventure", + "alternative world", + "comedy", + "demon", + "ecchi", + "fantasy", + "guns", + "harem", + "knight", + "magic", + "parody", + "robots & android", + "science-fiction", + "swords & co", + ) + } + } + + @Test + fun `extract exactly one tag`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/one_tag.html") + "<html></html>".writeToFile(tempDir.resolve("613.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly("drama") + } + } + + @Test + fun `no tags available`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/no_tags.html") + "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes defaults to 1`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/unknown.html") + "<html></html>".writeToFile(tempDir.resolve("16578.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `1 episode`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1.html") + "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/10.html") + "<html></html>".writeToFile(tempDir.resolve("3135.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/100.html") + "<html></html>".writeToFile(tempDir.resolve("4138.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1818.html") + "<html></html>".writeToFile(tempDir.resolve("5801.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + + @Test + fun `although not a double in json the number of episodes is deserialized to kotlin type double`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/type-is-double.html") + "<html></html>".writeToFile(tempDir.resolve("16804.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(6) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hour`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_hour.html") + "<html></html>".writeToFile(tempDir.resolve("6247.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 minute`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_minute.html") + "<html></html>".writeToFile(tempDir.resolve("11051.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `2 hours`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/2_hours.html") + "<html></html>".writeToFile(tempDir.resolve("6889.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `24 minutes per episode`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/24_minutes_per_episode.html") + "<html></html>".writeToFile(tempDir.resolve("14844.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) + } + } + + @Test + fun `63 minutes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/63_minutes_by_6_episodes.html") + "<html></html>".writeToFile(tempDir.resolve("7192.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(63, MINUTES)) + } + } + + @Test + fun `70 minutes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/70_minutes.html") + "<html></html>".writeToFile(tempDir.resolve("7163.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(70, MINUTES)) + } + } + + @Test + fun `134 minutes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/134_minutes.html") + "<html></html>".writeToFile(tempDir.resolve("602.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(134, MINUTES)) + } + } + + @Test + fun `episodes and duration unknown`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_and_duration_unknown.html") + "<html></html>".writeToFile(tempDir.resolve("16711.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `episodes known duration unknown`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_known_duration_unknown.html") + "<html></html>".writeToFile(tempDir.resolve("17039.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `multiple synonyms`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/multiple_synonyms.html") + "<html></html>".writeToFile(tempDir.resolve("1958.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Bésame Licia", + "Embrasse-moi Lucile", + "Kiss Me Licia", + "Love Me Night", + "Love in Rock 'n Roll", + "Lucile, Amour et Rock'n'Roll", + "Rock'n' Roll Kids", + "愛してナイト", + ) + } + } + + @Test + fun `no synonyms`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/no_synonyms.html") + "<html></html>".writeToFile(tempDir.resolve("16260.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `single synonym`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/single_synonym.html") + "<html></html>".writeToFile(tempDir.resolve("14456.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly( + "センコロール 3", + ) + } + } + + @Test + fun `single synonym and romanji alteration`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/romanji_alteration.html") + "<html></html>".writeToFile(tempDir.resolve("13631.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Chikyūgai Shōnen Shōjo", + "Extra-Terrestrial Boys & Girls", + "Extraterrestial Boys and Girls", + "Jóvenes en órbita", + "Notre jeunesse en orbite", + "The Orbital Children", + "地球外少年少女", + ) + } + } + + @Test + fun `correctly separate hidden synonyms using 11197 as an example`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_11197.html") + "<html></html>".writeToFile(tempDir.resolve("11197.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "DanMachi OVA", + "Danmachi OAV", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", + "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", + "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", + ) + setOf( + "Danmachi OAV", + "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darou ka", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "DanMachi OVA", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", + "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", + ) + } + } + + @Test + fun `correctly separate hidden synonyms using 8724 as an example`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_8724.html") + "<html></html>".writeToFile(tempDir.resolve("8724.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "My Teen Romantic Comedy SNAFU OVA", + "My Youth Romantic Comedy Is Wrong as I Expected. OVA", + "Oregairu OVA", + "Yahari Ore no Seishun Love Come wa Machigatte Iru. OAD", + "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto o Negawazaru o Enai.", + "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", + "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", + "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ōkaran Koto o Negawazaru o Enai.", + "やはり俺の青春ラブコメはまちがっている。「こちらとしても彼ら彼女らの行く末に幸多からんことを願わざるを得ない。」", + ) + } + } + + @Test + fun `synonyms containing named parts are not split`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/synonyms_contain_named_parts.html") + "<html></html>".writeToFile(tempDir.resolve("15599.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Extra One Room: Second Season: Hanasaka Yui Experiments / Hanasaka Yui Gets Delivered / Nanahashi Minori Holds a Meeting / Nanahashi Minori Becomes a Big-Shot / Amatsuki Mashiro Becomes a Cat / Amatsuki Mashiro Gives Treatment", + "Extra One Room: Second Season: Hanasaka Yui wa Tameshite Miru / Hanasaka Yui wa Okurarete Kuru / Nanahashi Minori wa Kaigi Suru / Nanahashi Minori wa Oomono ni Naru / Amatsuki Mashiro wa Neko ni Naru / Amatsuki Mashiro wa Chiryou Suru", + "One Room 2nd Season Extra", + "One Room Second Season Special", + "One Room セカンドシーズン -extra-", + ) + } + } + + @Test + fun `hidden synonyms and named parts`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_and_named_parts.html") + "<html></html>".writeToFile(tempDir.resolve("8093.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Youko x Boku SS Special", + "Inu x Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", + "Inu x Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", + "Inu × Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", + "Inu x Boku SS Special", + "妖狐×僕SS 御狐神くん変化 / スイッチ / おままごと", + "Youko × Boku SS Special", + "Inu × Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", + "Inu x Boku SS: Miketsukami-kun Henka / Switch / Omamagoto", + "Inu × Boku SS Special", + "Inu × Boku Secret Service: Miketsukami’s Metamorphosis", + ) + } + } + + @Test + fun `correctly extract synonyms in italic`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/italic.html") + "<html></html>".writeToFile(tempDir.resolve("17015.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly( + "Bakugan: Battle Planet Season 4", + "Bakugan: Battle Planet Staffel 4", + "爆丸エボリューションズ", + ) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'aborted' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/aborted.html") + "<html></html>".writeToFile(tempDir.resolve("12433.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `'canceled' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/canceled.html") + "<html></html>".writeToFile(tempDir.resolve("13270.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `'on hold' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/on_hold.html") + "<html></html>".writeToFile(tempDir.resolve("16925.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `no status is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/no_status.html") + "<html></html>".writeToFile(tempDir.resolve("14494.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `'completed' is mapped to 'FINISHED'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `completed is only based on the original release`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed_in_japan_upcoming_elsewhere.html") + "<html></html>".writeToFile(tempDir.resolve("6222.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `'ongoing' is mapped to ONGOING`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/ongoing.html") + "<html></html>".writeToFile(tempDir.resolve("1721.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'upcoming' is mapped to UPCOMING`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/upcoming.html") + "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @ParameterizedTest + @CsvSource(value = [ + "01, 14628", + "02, 14967", + "03, 16251", + ]) + fun `'jan', 'feb', and 'mar' are mapped to WINTER`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "04, 14508", + "05, 15264", + "06, 14935", + ]) + fun `'apr', 'may' and 'jul' are mapped to SPRING`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "07, 14816", + "08, 16401", + "09, 16401", + ]) + fun `'jul', 'aug' and 'sep' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "10, 15606", + "11, 15042", + "12, 14484", + ]) + fun `'oct', 'nov' and 'dec' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "year_only, 17467", + "unknown, 16275", + ]) + fun `if the month cannot be determined use UNDEFINED`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `2021-08-06`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/2021-08-06.html") + "<html></html>".writeToFile(tempDir.resolve("15890.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2021) + } + } + + @Test + fun `1958-11`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1958-11.html") + "<html></html>".writeToFile(tempDir.resolve("5976.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1958) + } + } + + @Test + fun `1991`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1991.html") + "<html></html>".writeToFile(tempDir.resolve("168.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1991) + } + } + + @Test + fun `unknown year`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/unknown.html") + "<html></html>".writeToFile(tempDir.resolve("16275.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no related anime but a single adaption`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption.html") + .copyTo(tempDir.resolve("14844.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no related anime`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime.html") + .copyTo(tempDir.resolve("10941.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `single related anime`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/single_related_anime_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/single_related_anime.html") + .copyTo(tempDir.resolve("16777.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://anisearch.com/anime/17530")) + ) + } + } + + @Test + fun `multiple related anime`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/multiple_related_anime_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/multiple_related_anime.html") + .copyTo(tempDir.resolve("4942.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anisearch.com/anime/2088"), "Remake"), + RelatedAnime(URI("https://anisearch.com/anime/2753"), "Sequel"), + ) + } + } + + @Test + fun `throws exception if relations file is missing`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/related_anime_file_missing_main.html") + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = exceptionExpected<IllegalStateException> { + converter.convert(testFile) + } + + // then + assertThat(result).hasMessage("Relations file is missing for [4942].") + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/score.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).hasSize(1) + assertThat((result.scores.first()).scaledValue()).isEqualTo(9.081632653061224) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/no-score.html") + "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/multiple_studios.html") + "<html></html>".writeToFile(tempDir.resolve("1945.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "pierrot co., ltd.", + "studio deen co., ltd.", + ) + } + } + + @Test + fun `no studios`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/no_studios.html") + "<html></html>".writeToFile(tempDir.resolve("5419.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ConstructorTests { + + @Test + fun `throws exception if relationsDir doesn't exist`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + // when + val result = exceptionExpected<IllegalArgumentException> { + AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir.resolve("relations"), + ) + } + + // then + assertThat(result).hasMessage("Directory for relations [${tempDir.resolve("relations")}] does not exist or is not a directory.") + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/Anime.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/Anime.kt index a23fd95b..a6e12f61 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/Anime.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/Anime.kt @@ -24,7 +24,7 @@ import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNK * @property synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. * @property studios Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. * @property producers Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. - * @property relatedAnime Duplicate-free list of links to related anime. + * @property relatedAnime Duplicate-free list of links to related anime including the kind of relation as labelled by the metadata provider. One entry per link. * @property tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. */ public data class Anime( @@ -41,7 +41,7 @@ public data class Anime( val synonyms: HashSet<Title> = HashSet(), val studios: HashSet<Studio> = HashSet(), val producers: HashSet<Producer> = HashSet(), - val relatedAnime: HashSet<URI> = HashSet(), + val relatedAnime: HashSet<RelatedAnime> = HashSet(), val tags: HashSet<Tag> = HashSet(), ) { diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt index b72df026..c48a4bed 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt @@ -1,673 +1,703 @@ -package io.github.manamiproject.modb.core.anime - -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import io.github.manamiproject.modb.core.extensions.normalize -import java.net.URI -import java.util.concurrent.ConcurrentHashMap -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - - -/** - * This class represents an anime directly converted from raw data of a metadata provider. - * It is also the type used to merge multiple anime of different metadata providers. - * @since 3.1.0 - * @param _title Main title. Must not be blank. - * @property title Main title. - * @param _sources Duplicate-free list of sources from which this anime was created. - * @property sources Duplicate-free list of sources from which this anime was created. - * @property type Distribution type. **Default** is [AnimeType.UNKNOWN]. - * @property episodes Number of episodes. **Default** is `0`. - * @property status Publishing status. **Default** is [AnimeStatus.UNKNOWN]. - * @property animeSeason In which season did the anime premiere. - * @property picture [URI] to a (large) poster/cover. **Default** is a self created "not found" pic. - * @property thumbnail [URI] to a thumbnail poster/cover. **Default** is a self created "not found" pic. - * @property duration Duration of an anime having one episode or average duration of an episode if the anime has more than one episode. - * @property scores List of scores provided by metadata providers. - * @param _synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. - * @property synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. - * @param _studios List of studio names as provided by the metadata providers. - * @property studios List of studio names. May contain duplicates for slightly different writings. All studio names are lower case. - * @param _producers List of names of producers as provided by the metadata providers. - * @property producers List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. - * @param _relatedAnime Duplicate-free list of links to related anime. - * @property relatedAnime Duplicate-free list of links to related anime. - * @param _tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags transformed to lower case. - * @property tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. - * @property metaDataProviderScores Contains a score for each metadata provider. The property will not be serialized as-is. It's serialized using backing field [scores]. - * @property activateChecks Disable any checks upon creating the object. This is only supposed to be used during safe deserialization. If created using `false` you can call [performChecks] manually. The property will not be serialized - * @throws IllegalArgumentException if _title is blank or number of episodes is negative. - */ -public data class AnimeRaw( - private var _title: Title, - private val _sources: HashSet<URI> = HashSet(), - val type: AnimeType = UNKNOWN_TYPE, - val episodes: Episodes = 0, - val status: AnimeStatus = UNKNOWN_STATUS, - val animeSeason: AnimeSeason = AnimeSeason(), - val picture: URI = NO_PICTURE, - val thumbnail: URI = NO_PICTURE_THUMBNAIL, - val duration: Duration = UNKNOWN_DURATION, - private val _synonyms: HashSet<Title> = HashSet(), - private val _studios: HashSet<Studio> = HashSet(), - private val _producers: HashSet<Producer> = HashSet(), - private val _relatedAnime: HashSet<URI> = HashSet(), - private val _tags: HashSet<Tag> = HashSet(), - @Transient private val metaDataProviderScores: HashMap<Hostname, MetaDataProviderScoreValue> = hashMapOf(), - @Transient val activateChecks: Boolean = true, -) { - - /** - * Main title. - * @since 1.0.0 - */ - val title: Title - get() = _title - - /** - * Duplicate-free list of sources from which this anime was created. - * @since 16.6.0 - */ - val sources: HashSet<URI> - get() = _sources.toHashSet() - - /** - * Duplicate-free list of alternative titles. Synonyms are case-sensitive. - * @since 16.6.0 - */ - val synonyms: HashSet<Title> - get() = _synonyms.toHashSet() - - /** - * List of studio names. May contain duplicates for slightly different writings. All studios names are lower case. - * @since 18.4.0 - */ - val studios: HashSet<Studio> - get() = _studios.toHashSet() - - /** - * List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. - * @since 18.4.0 - */ - val producers: HashSet<Producer> - get() = _producers.toHashSet() - - /** - * Duplicate-free list of links to related anime. - * @since 16.6.0 - */ - val relatedAnime: HashSet<URI> - get() = _relatedAnime.toHashSet() - - /** - * Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. - * @since 16.6.0 - */ - val tags: HashSet<Tag> - get() = _tags.toHashSet() - - /** - * Duplicate-free list of scores. - * @since 17.0.0 - */ - val scores: HashSet<MetaDataProviderScoreValue> - get() = metaDataProviderScores.values.toHashSet() - - init { - require(title.neitherNullNorBlank()) { "Title cannot be blank." } - - if (activateChecks) { - performChecks() - } - } - - /** - * Add additional synonyms to the existing list. Duplicates are being ignored. - * Comparison for this is case-sensitive. This will **not** override [synonyms]. - * The value which is present in [title] cannot be added. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but - * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. - * @since 3.1.0 - * @param synonym Synonyms to be added. - * @return Same instance. - */ - public fun addSynonyms(vararg synonym: Title): AnimeRaw = addSynonyms(synonym.toHashSet()) - - /** - * Add additional synonyms to the existing list. Duplicates are being ignored. - * Comparison for this is case-sensitive. This will **not** override [synonyms]. - * The value which is present in [title] cannot be added. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but - * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. - * @since 1.0.0 - * @param synonyms List of synonyms. - * @return Same instance. - */ - public fun addSynonyms(synonyms: Collection<Title>): AnimeRaw { - synonyms.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .filter { it != _title } - .map { normalizeApostrophes(it, CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY) } - .forEach { _synonyms.add(it) } - - return this - } - - /** - * Removes a [Title] from [synonyms] if the given condition matches. - * @since 19.2.0 - * @param condition If an entry in [synonyms] matches this condition, then the [Title] will be removed from [synonyms]. - * @return Same instance. - */ - public fun removeSynonymIf(condition: (Title) -> Boolean): AnimeRaw { - _synonyms.removeIf { condition.invoke(it) } - return this - } - - /** - * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. - * To further prevent duplicates, the longest studio name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param studios Studio names to be added. - * @return Same instance. - */ - public fun addStudios(vararg studios: Studio): AnimeRaw = addStudios(studios.toHashSet()) - - /** - * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. - * To further prevent duplicates, the longest studio name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param studios List of studio names to be added. - * @return Same instance. - */ - public fun addStudios(studios: Collection<Studio>): AnimeRaw { - val newNormalized = studios.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .map { it.lowercase() } - .map { normalizeApostrophes(it) } - .toSet() - - val union =_studios.toHashSet().union(newNormalized).sortedBy { it.length } - - val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.startsWith(check) } - .any { lookup -> lookup.length > check.length } - }.filterNot { check -> // prevent duplicates with same suffix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.endsWith(check) } - .any { lookup -> lookup.length > check.length } - } - - _studios.clear() - removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _studios.add(it) } - - return this - } - - /** - * Add additional names of producers to the existing list. Producers are added in lower case. - * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param producers names of producers to be added. - * @return Same instance. - */ - public fun addProducers(vararg producers: Producer): AnimeRaw = addProducers(producers.toHashSet()) - - /** - * Add additional names of producers to the existing list. Producers are added in lower case. - * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param producers List of names of producers to be added. - * @return Same instance. - */ - public fun addProducers(producers: Collection<Producer>): AnimeRaw { - val newNormalized = producers.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .map { it.lowercase() } - .map { normalizeApostrophes(it) } - .toSet() - - val union =_producers.toHashSet().union(newNormalized).sortedBy { it.length } - - val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.startsWith(check) } - .any { lookup -> lookup.length > check.length } - }.filterNot { check -> // prevent duplicates with same suffix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.endsWith(check) } - .any { lookup -> lookup.length > check.length } - } - - _producers.clear() - removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _producers.add(it) } - - return this - } - - /** - * Add additional sources to the existing list. This will **not** override [sources]. - * Duplicates are being ignored. - * @since 3.1.0 - * @param source Sources to be added. - * @return Same instance. - */ - public fun addSources(vararg source: URI): AnimeRaw = addSources(source.toHashSet()) - - /** - * Add additional sources to the existing list. This will **not** override [sources]. - * Duplicates are being ignored. - * @since 3.0.0 - * @param sources List of sources. - * @return Same instance. - */ - public fun addSources(sources: Collection<URI>): AnimeRaw { - _sources.addAll(sources) - - removeRelatedAnimeIf { sources.contains(it) } - - return this - } - - /** - * Removes an [URI] from [sources] if the given condition matches. - * @since 16.6.0 - * @param condition If an entry in [sources] matches this condition, then the [URI] will be removed from [sources]. - * @return Same instance. - */ - public fun removeSourceIf(condition: (URI) -> Boolean): AnimeRaw { - _sources.removeIf { condition.invoke(it) } - return this - } - - /** - * Add additional related anime to the existing list. This will **not** override [relatedAnime]. - * Duplicates are being ignored. - * @since 11.0.0 - * @param relatedAnime List of related anime. - * @return Same instance. - */ - public fun addRelatedAnime(vararg relatedAnime: URI): AnimeRaw = addRelatedAnime(relatedAnime.toHashSet()) - - /** - * Add additional related anime to the existing list. This will **not** override [relatedAnime]. - * Duplicates are being ignored. - * @since 11.0.0 - * @param relatedAnime List of related anime. - * @return Same instance. - */ - public fun addRelatedAnime(relatedAnime: Collection<URI>): AnimeRaw { - relatedAnime.asSequence() - .filter { !_sources.contains(it) } - .forEach { _relatedAnime.add(it) } - - return this - } - - /** - * Removes an [URI] from [relatedAnime] if the given condition matches. - * @since 11.0.0 - * @param condition If an entry in [relatedAnime] matches this condition, then the [URI] will be removed from [relatedAnime]. - * @return Same instance. - */ - public fun removeRelatedAnimeIf(condition: (URI) -> Boolean): AnimeRaw { - _relatedAnime.removeIf { condition.invoke(it) } - return this - } - - /** - * Add additional tags to the existing list. This will **not** override [tags]. - * Duplicates are being ignored. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 3.1.0 - * @param tags List of tags. - * @return Same instance. - */ - public fun addTags(vararg tags: Tag): AnimeRaw = addTags(tags.toHashSet()) - - /** - * Add additional tags to the existing list. This will **not** override [tags]. - * Duplicates are being ignored. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 1.0.0 - * @param tags List of tags. - * @return Same instance. - */ - public fun addTags(tags: Collection<Tag>): AnimeRaw { - tags.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .map { it.lowercase() } - .map { normalizeApostrophes(it) } - .forEach { _tags.add(it) } - - return this - } - - /** - * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. - * Only one instance of a score per metadata provider is supported. Latest score added wins. - * @since 17.0.0 - * @param scores List of scores provided by metadata providers. - * @return Same instance. - */ - public fun addScores(vararg scores: MetaDataProviderScore): AnimeRaw = addScores(scores.toHashSet()) - - /** - * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. - * Only one instance of a score per metadata provider is supported. Latest score added wins. - * @since 17.0.0 - * @param scores A score of a metadata provider. - * @return Same instance. - */ - public fun addScores(scores: Collection<MetaDataProviderScore>): AnimeRaw { - scores.filterIsInstance<MetaDataProviderScoreValue>().forEach { score -> - metaDataProviderScores[score.hostname] = score - } - - return this - } - - /** - * + Title and synonyms of the given [AnimeRaw] will both be added to the [synonyms] of this instance. - * + All sources of the given [AnimeRaw] will be added to the [sources] of this instance. - * + All related anime of the given [AnimeRaw] will be added to the [relatedAnime] of this instance. - * + All tags of the given [AnimeRaw] will be added to the [tags] of this instance. - * + All studios of the given [AnimeRaw] will be added to the [studios] of this instance. - * + All producers of the given [AnimeRaw] will be added to the [producers] of this instance. - * + In case the number of episodes of this instance is 0, the value of the given [AnimeRaw] will be applied. - * + In case the type of this instance is [AnimeType.UNKNOWN], the value of the given [AnimeRaw] will be applied. - * + In case the status of this instance is [AnimeStatus.UNKNOWN], the value of the given [AnimeRaw] will be applied. - * + In case the duration of this instance is [Duration.UNKNOWN], the value of the given [AnimeRaw] will be applied. - * + In case the season of this instance's [animeSeason] is [UNDEFINED], the season of the given [AnimeRaw] will be applied. - * + In case the year of this instance's [animeSeason] is [AnimeSeason.UNKNOWN_YEAR], the year if the given [AnimeRaw] will be applied. - * + All scores are collected. However, only one instance per metadata provider is kept. Latest score added wins. - * @since 1.0.0 - * @param anime [AnimeRaw] which is being merged into this instance. - * @return New instance of the merged anime. - */ - public fun mergeWith(anime: AnimeRaw): AnimeRaw { - val mergedEpisodes = if (episodes == 0 && anime.episodes != 0) { - anime.episodes - } else { - episodes - } - - val mergedType = if (type == UNKNOWN_TYPE && anime.type != UNKNOWN_TYPE) { - anime.type - } else { - type - } - - val mergedStatus = if (status == UNKNOWN_STATUS && anime.status != UNKNOWN_STATUS) { - anime.status - } else { - status - } - - val mergedDuration = if (duration == UNKNOWN_DURATION && anime.duration != UNKNOWN_DURATION) { - anime.duration - } else { - duration - } - - val mergedSeason = if (animeSeason.season == UNDEFINED && anime.animeSeason.season != UNDEFINED) { - anime.animeSeason.season - } else { - animeSeason.season - } - - val mergedYear = if (animeSeason.isYearOfPremiereUnknown() && anime.animeSeason.isYearOfPremiereKnown()) { - anime.animeSeason.year - } else { - animeSeason.year - } - - return AnimeRaw( - _title = title, - type = mergedType, - episodes = mergedEpisodes, - status = mergedStatus, - picture = picture, - thumbnail = thumbnail, - duration = mergedDuration, - animeSeason = AnimeSeason( - season = mergedSeason, - year = mergedYear, - ), - ).addSources(sources) - .addSources(anime.sources) - .addSynonyms(_synonyms) - .addSynonyms(anime.title) - .addSynonyms(anime.synonyms) - .addRelatedAnime(_relatedAnime) - .addRelatedAnime(anime.relatedAnime) - .addTags(_tags) - .addTags(anime.tags) - .addStudios(_studios) - .addStudios(anime.studios) - .addProducers(_producers) - .addProducers(anime.producers) - .addScores(metaDataProviderScores.values) - .addScores(anime.scores) - } - - /** - * Performs checks and fixes data. - * @since 11.0.0 - * @return Same instance of the anime. - * @throws IllegalArgumentException if _title is blank or number of episodes is negative. - */ - public fun performChecks(): AnimeRaw { - _title = _title.normalize() - require(_title.neitherNullNorBlank()) { "Title cannot be blank." } - - require(episodes >= 0) { "Episodes cannot have a negative value." } - - val uncheckedSources: Collection<URI> = _sources.toSet() - _sources.clear() - addSources(uncheckedSources) - - val uncheckedSynonyms: Collection<Title> = _synonyms.toSet() - _synonyms.clear() - addSynonyms(uncheckedSynonyms) - - val uncheckedStudios: Collection<Title> = _studios.toSet() - _studios.clear() - addStudios(uncheckedStudios) - - val uncheckedProducers: Collection<Title> = _producers.toSet() - _producers.clear() - addProducers(uncheckedProducers) - - val uncheckedRelatedAnime: Collection<URI> = _relatedAnime.toSet() - _relatedAnime.clear() - addRelatedAnime(uncheckedRelatedAnime) - - val uncheckedTags: Collection<Tag> = _tags.toList() - _tags.clear() - addTags(uncheckedTags) - - return this - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AnimeRaw - - if (_title != other._title) return false - if (_sources != other.sources) return false - if (type != other.type) return false - if (episodes != other.episodes) return false - if (status != other.status) return false - if (animeSeason != other.animeSeason) return false - if (picture != other.picture) return false - if (thumbnail != other.thumbnail) return false - if (duration != other.duration) return false - if (scores != other.scores) return false - if (_synonyms != other.synonyms) return false - if (_studios != other.studios) return false - if (_producers != other.producers) return false - if (_relatedAnime != other.relatedAnime) return false - if (_tags != other.tags) return false - - return true - } - - override fun hashCode(): Int { - var result = _title.hashCode() - result = 31 * result + _sources.hashCode() - result = 31 * result + type.hashCode() - result = 31 * result + episodes - result = 31 * result + status.hashCode() - result = 31 * result + animeSeason.hashCode() - result = 31 * result + picture.hashCode() - result = 31 * result + thumbnail.hashCode() - result = 31 * result + duration.hashCode() - result = 31 * result + metaDataProviderScores.values.sorted().hashCode() - result = 31 * result + _synonyms.hashCode() - result = 31 * result + _studios.hashCode() - result = 31 * result + _producers.hashCode() - result = 31 * result + _relatedAnime.hashCode() - result = 31 * result + _tags.hashCode() - return result - } - - override fun toString(): String { - return """ - AnimeRaw( - sources = ${_sources.sorted()} - title = $_title - type = $type - episodes = $episodes - status = $status - animeSeason = $animeSeason - picture = $picture - thumbnail = $thumbnail - duration = $duration - scores = ${metaDataProviderScores.values.sortedBy { it.hostname }} - synonyms = ${_synonyms.sorted()} - studios = ${_studios.sorted()} - producers = ${_producers.sorted()} - relatedAnime = ${_relatedAnime.sorted()} - tags = ${_tags.sorted()} - ) - """.trimIndent() - } - - private fun removeOverlappingDuplicates(list: Collection<String>): Collection<String> { - if (list.isEmpty() || list.size == 1) { - return list - } - - val ret = ConcurrentHashMap.newKeySet<String>(list.size).apply { addAll(list) } - var lastSize: Int - - do { - lastSize = ret.size - - for (outerName in ret) { - val last = outerName.split(' ').lastOrNull() ?: continue - - for (innerName in ret) { - val first = innerName.split(' ').firstOrNull() ?: continue - - if (last == first && outerName != innerName && last !in falsePositives) { - when { - outerName.length > innerName.length -> ret.remove(innerName) - else -> ret.remove(outerName) - } - } - } - } - } while (lastSize > ret.size) - - return ret - } - - private fun normalizeApostrophes(value: String, normalizeTo: Char = SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY): String { - var normalized = value - - val apostropheAsSuffix = when { - normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX - else -> APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX - } - - if (apostropheAsSuffix.containsMatchIn(normalized)) { - apostropheAsSuffix.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> - normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) - } - } - - val apostropheUsage = when { - normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_USAGE_INCLUDING_U2019_REGEX - else -> APOSTROPH_USAGE_EXCEPT_U2019_REGEX - } - - if (apostropheUsage.containsMatchIn(normalized)) { - apostropheUsage.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> - normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) - } - } - - return normalized - } - - private companion object { - private const val CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY: Char = '\u2019' - private const val SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY: Char = '\u0027' - private val APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() - private val APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() - private val APOSTROPH_USAGE_EXCEPT_U2019_REGEX: Regex = """(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() - private val APOSTROPH_USAGE_INCLUDING_U2019_REGEX: Regex = """(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() - private val falsePositives = hashSetOf<String>( - "anime", - "studio", - "tv", - "tokyo", - "groove", - "media", - "production", - "animation", - "television", - "group", - ) - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.anime + +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import io.github.manamiproject.modb.core.extensions.normalize +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + + +/** + * This class represents an anime directly converted from raw data of a metadata provider. + * It is also the type used to merge multiple anime of different metadata providers. + * @since 3.1.0 + * @param _title Main title. Must not be blank. + * @property title Main title. + * @param _sources Duplicate-free list of sources from which this anime was created. + * @property sources Duplicate-free list of sources from which this anime was created. + * @property type Distribution type. **Default** is [AnimeType.UNKNOWN]. + * @property episodes Number of episodes. **Default** is `0`. + * @property status Publishing status. **Default** is [AnimeStatus.UNKNOWN]. + * @property animeSeason In which season did the anime premiere. + * @property picture [URI] to a (large) poster/cover. **Default** is a self created "not found" pic. + * @property thumbnail [URI] to a thumbnail poster/cover. **Default** is a self created "not found" pic. + * @property duration Duration of an anime having one episode or average duration of an episode if the anime has more than one episode. + * @property scores List of scores provided by metadata providers. + * @param _synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. + * @property synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. + * @param _studios List of studio names as provided by the metadata providers. + * @property studios List of studio names. May contain duplicates for slightly different writings. All studio names are lower case. + * @param _producers List of names of producers as provided by the metadata providers. + * @property producers List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. + * @param _relatedAnime Duplicate-free list of links to related anime including the kind of relation. One entry per link. + * @property relatedAnime Duplicate-free list of links to related anime including the kind of relation. One entry per link. + * @param _tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags transformed to lower case. + * @property tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. + * @property metaDataProviderScores Contains a score for each metadata provider. The property will not be serialized as-is. It's serialized using backing field [scores]. + * @property activateChecks Disable any checks upon creating the object. This is only supposed to be used during safe deserialization. If created using `false` you can call [performChecks] manually. The property will not be serialized + * @throws IllegalArgumentException if _title is blank or number of episodes is negative. + */ +public data class AnimeRaw( + private var _title: Title, + private val _sources: HashSet<URI> = HashSet(), + val type: AnimeType = UNKNOWN_TYPE, + val episodes: Episodes = 0, + val status: AnimeStatus = UNKNOWN_STATUS, + val animeSeason: AnimeSeason = AnimeSeason(), + val picture: URI = NO_PICTURE, + val thumbnail: URI = NO_PICTURE_THUMBNAIL, + val duration: Duration = UNKNOWN_DURATION, + private val _synonyms: HashSet<Title> = HashSet(), + private val _studios: HashSet<Studio> = HashSet(), + private val _producers: HashSet<Producer> = HashSet(), + private val _relatedAnime: HashSet<RelatedAnime> = HashSet(), + private val _tags: HashSet<Tag> = HashSet(), + @Transient private val metaDataProviderScores: HashMap<Hostname, MetaDataProviderScoreValue> = hashMapOf(), + @Transient val activateChecks: Boolean = true, +) { + + /** + * Main title. + * @since 1.0.0 + */ + val title: Title + get() = _title + + /** + * Duplicate-free list of sources from which this anime was created. + * @since 16.6.0 + */ + val sources: HashSet<URI> + get() = _sources.toHashSet() + + /** + * Duplicate-free list of alternative titles. Synonyms are case-sensitive. + * @since 16.6.0 + */ + val synonyms: HashSet<Title> + get() = _synonyms.toHashSet() + + /** + * List of studio names. May contain duplicates for slightly different writings. All studios names are lower case. + * @since 18.4.0 + */ + val studios: HashSet<Studio> + get() = _studios.toHashSet() + + /** + * List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. + * @since 18.4.0 + */ + val producers: HashSet<Producer> + get() = _producers.toHashSet() + + /** + * Duplicate-free list of links to related anime including the kind of relation. One entry per link. + * @since 20.0.0 + */ + val relatedAnime: HashSet<RelatedAnime> + get() = _relatedAnime.toHashSet() + + /** + * Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. + * @since 16.6.0 + */ + val tags: HashSet<Tag> + get() = _tags.toHashSet() + + /** + * Duplicate-free list of scores. + * @since 17.0.0 + */ + val scores: HashSet<MetaDataProviderScoreValue> + get() = metaDataProviderScores.values.toHashSet() + + init { + require(title.neitherNullNorBlank()) { "Title cannot be blank." } + + if (activateChecks) { + performChecks() + } + } + + /** + * Add additional synonyms to the existing list. Duplicates are being ignored. + * Comparison for this is case-sensitive. This will **not** override [synonyms]. + * The value which is present in [title] cannot be added. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but + * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. + * @since 3.1.0 + * @param synonym Synonyms to be added. + * @return Same instance. + */ + public fun addSynonyms(vararg synonym: Title): AnimeRaw = addSynonyms(synonym.toHashSet()) + + /** + * Add additional synonyms to the existing list. Duplicates are being ignored. + * Comparison for this is case-sensitive. This will **not** override [synonyms]. + * The value which is present in [title] cannot be added. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but + * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. + * @since 1.0.0 + * @param synonyms List of synonyms. + * @return Same instance. + */ + public fun addSynonyms(synonyms: Collection<Title>): AnimeRaw { + synonyms.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .filter { it != _title } + .map { normalizeApostrophes(it, CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY) } + .forEach { _synonyms.add(it) } + + return this + } + + /** + * Removes a [Title] from [synonyms] if the given condition matches. + * @since 19.2.0 + * @param condition If an entry in [synonyms] matches this condition, then the [Title] will be removed from [synonyms]. + * @return Same instance. + */ + public fun removeSynonymIf(condition: (Title) -> Boolean): AnimeRaw { + _synonyms.removeIf { condition.invoke(it) } + return this + } + + /** + * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. + * To further prevent duplicates, the longest studio name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param studios Studio names to be added. + * @return Same instance. + */ + public fun addStudios(vararg studios: Studio): AnimeRaw = addStudios(studios.toHashSet()) + + /** + * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. + * To further prevent duplicates, the longest studio name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param studios List of studio names to be added. + * @return Same instance. + */ + public fun addStudios(studios: Collection<Studio>): AnimeRaw { + val newNormalized = studios.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .map { it.lowercase() } + .map { normalizeApostrophes(it) } + .toSet() + + val union =_studios.toHashSet().union(newNormalized).sortedBy { it.length } + + val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.startsWith(check) } + .any { lookup -> lookup.length > check.length } + }.filterNot { check -> // prevent duplicates with same suffix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.endsWith(check) } + .any { lookup -> lookup.length > check.length } + } + + _studios.clear() + removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _studios.add(it) } + + return this + } + + /** + * Add additional names of producers to the existing list. Producers are added in lower case. + * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param producers names of producers to be added. + * @return Same instance. + */ + public fun addProducers(vararg producers: Producer): AnimeRaw = addProducers(producers.toHashSet()) + + /** + * Add additional names of producers to the existing list. Producers are added in lower case. + * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param producers List of names of producers to be added. + * @return Same instance. + */ + public fun addProducers(producers: Collection<Producer>): AnimeRaw { + val newNormalized = producers.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .map { it.lowercase() } + .map { normalizeApostrophes(it) } + .toSet() + + val union =_producers.toHashSet().union(newNormalized).sortedBy { it.length } + + val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.startsWith(check) } + .any { lookup -> lookup.length > check.length } + }.filterNot { check -> // prevent duplicates with same suffix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.endsWith(check) } + .any { lookup -> lookup.length > check.length } + } + + _producers.clear() + removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _producers.add(it) } + + return this + } + + /** + * Add additional sources to the existing list. This will **not** override [sources]. + * Duplicates are being ignored. + * @since 3.1.0 + * @param source Sources to be added. + * @return Same instance. + */ + public fun addSources(vararg source: URI): AnimeRaw = addSources(source.toHashSet()) + + /** + * Add additional sources to the existing list. This will **not** override [sources]. + * Duplicates are being ignored. + * @since 3.0.0 + * @param sources List of sources. + * @return Same instance. + */ + public fun addSources(sources: Collection<URI>): AnimeRaw { + _sources.addAll(sources) + + removeRelatedAnimeIf { sources.contains(it) } + + return this + } + + /** + * Removes an [URI] from [sources] if the given condition matches. + * @since 16.6.0 + * @param condition If an entry in [sources] matches this condition, then the [URI] will be removed from [sources]. + * @return Same instance. + */ + public fun removeSourceIf(condition: (URI) -> Boolean): AnimeRaw { + _sources.removeIf { condition.invoke(it) } + return this + } + + /** + * Add additional related anime to the existing list. This will **not** override [relatedAnime]. + * Links which are part of [sources] are ignored. There is only one entry per link: an entry having a + * [RelatedAnime.relation] replaces an existing entry without one, otherwise the existing entry is kept. + * @since 20.0.0 + * @param relatedAnime Related anime including the kind of relation. + * @return Same instance. + */ + public fun addRelatedAnime(vararg relatedAnime: RelatedAnime): AnimeRaw = addRelatedAnime(relatedAnime.toList()) + + /** + * Add additional related anime to the existing list. This will **not** override [relatedAnime]. + * Links which are part of [sources] are ignored. There is only one entry per link: an entry having a + * [RelatedAnime.relation] replaces an existing entry without one, otherwise the existing entry is kept. + * @since 20.0.0 + * @param relatedAnime List of related anime including the kind of relation. + * @return Same instance. + */ + public fun addRelatedAnime(relatedAnime: Collection<RelatedAnime>): AnimeRaw { + relatedAnime.asSequence() + .filter { !_sources.contains(it.uri) } + .forEach { new -> + val existing = _relatedAnime.firstOrNull { it.uri == new.uri } + when { + existing == null -> _relatedAnime.add(new) + existing.relation == null && new.relation != null -> { + _relatedAnime.remove(existing) + _relatedAnime.add(new) + } + } + } + + return this + } + + /** + * Add additional related anime for which the kind of relation is unknown. + * This will **not** override [relatedAnime]. See [addRelatedAnime]. + * @since 11.0.0 + * @param relatedAnime Links to related anime. + * @return Same instance. + */ + public fun addRelatedAnime(vararg relatedAnime: URI): AnimeRaw = addRelatedAnime(relatedAnime.map { RelatedAnime(it) }) + + /** + * Add additional related anime for which the kind of relation is unknown. + * This will **not** override [relatedAnime]. See [addRelatedAnime]. + * @since 11.0.0 + * @param relatedAnime Links to related anime. + * @return Same instance. + */ + @JvmName("addRelatedAnimeUris") + public fun addRelatedAnime(relatedAnime: Collection<URI>): AnimeRaw = addRelatedAnime(relatedAnime.map { RelatedAnime(it) }) + + /** + * Removes an entry from [relatedAnime] if the given condition matches its link. + * @since 11.0.0 + * @param condition If the link of an entry in [relatedAnime] matches this condition, then the entry will be removed from [relatedAnime]. + * @return Same instance. + */ + public fun removeRelatedAnimeIf(condition: (URI) -> Boolean): AnimeRaw { + _relatedAnime.removeIf { condition.invoke(it.uri) } + return this + } + + /** + * Add additional tags to the existing list. This will **not** override [tags]. + * Duplicates are being ignored. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 3.1.0 + * @param tags List of tags. + * @return Same instance. + */ + public fun addTags(vararg tags: Tag): AnimeRaw = addTags(tags.toHashSet()) + + /** + * Add additional tags to the existing list. This will **not** override [tags]. + * Duplicates are being ignored. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 1.0.0 + * @param tags List of tags. + * @return Same instance. + */ + public fun addTags(tags: Collection<Tag>): AnimeRaw { + tags.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .map { it.lowercase() } + .map { normalizeApostrophes(it) } + .forEach { _tags.add(it) } + + return this + } + + /** + * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. + * Only one instance of a score per metadata provider is supported. Latest score added wins. + * @since 17.0.0 + * @param scores List of scores provided by metadata providers. + * @return Same instance. + */ + public fun addScores(vararg scores: MetaDataProviderScore): AnimeRaw = addScores(scores.toHashSet()) + + /** + * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. + * Only one instance of a score per metadata provider is supported. Latest score added wins. + * @since 17.0.0 + * @param scores A score of a metadata provider. + * @return Same instance. + */ + public fun addScores(scores: Collection<MetaDataProviderScore>): AnimeRaw { + scores.filterIsInstance<MetaDataProviderScoreValue>().forEach { score -> + metaDataProviderScores[score.hostname] = score + } + + return this + } + + /** + * + Title and synonyms of the given [AnimeRaw] will both be added to the [synonyms] of this instance. + * + All sources of the given [AnimeRaw] will be added to the [sources] of this instance. + * + All related anime of the given [AnimeRaw] will be added to the [relatedAnime] of this instance. + * + All tags of the given [AnimeRaw] will be added to the [tags] of this instance. + * + All studios of the given [AnimeRaw] will be added to the [studios] of this instance. + * + All producers of the given [AnimeRaw] will be added to the [producers] of this instance. + * + In case the number of episodes of this instance is 0, the value of the given [AnimeRaw] will be applied. + * + In case the type of this instance is [AnimeType.UNKNOWN], the value of the given [AnimeRaw] will be applied. + * + In case the status of this instance is [AnimeStatus.UNKNOWN], the value of the given [AnimeRaw] will be applied. + * + In case the duration of this instance is [Duration.UNKNOWN], the value of the given [AnimeRaw] will be applied. + * + In case the season of this instance's [animeSeason] is [UNDEFINED], the season of the given [AnimeRaw] will be applied. + * + In case the year of this instance's [animeSeason] is [AnimeSeason.UNKNOWN_YEAR], the year if the given [AnimeRaw] will be applied. + * + All scores are collected. However, only one instance per metadata provider is kept. Latest score added wins. + * @since 1.0.0 + * @param anime [AnimeRaw] which is being merged into this instance. + * @return New instance of the merged anime. + */ + public fun mergeWith(anime: AnimeRaw): AnimeRaw { + val mergedEpisodes = if (episodes == 0 && anime.episodes != 0) { + anime.episodes + } else { + episodes + } + + val mergedType = if (type == UNKNOWN_TYPE && anime.type != UNKNOWN_TYPE) { + anime.type + } else { + type + } + + val mergedStatus = if (status == UNKNOWN_STATUS && anime.status != UNKNOWN_STATUS) { + anime.status + } else { + status + } + + val mergedDuration = if (duration == UNKNOWN_DURATION && anime.duration != UNKNOWN_DURATION) { + anime.duration + } else { + duration + } + + val mergedSeason = if (animeSeason.season == UNDEFINED && anime.animeSeason.season != UNDEFINED) { + anime.animeSeason.season + } else { + animeSeason.season + } + + val mergedYear = if (animeSeason.isYearOfPremiereUnknown() && anime.animeSeason.isYearOfPremiereKnown()) { + anime.animeSeason.year + } else { + animeSeason.year + } + + return AnimeRaw( + _title = title, + type = mergedType, + episodes = mergedEpisodes, + status = mergedStatus, + picture = picture, + thumbnail = thumbnail, + duration = mergedDuration, + animeSeason = AnimeSeason( + season = mergedSeason, + year = mergedYear, + ), + ).addSources(sources) + .addSources(anime.sources) + .addSynonyms(_synonyms) + .addSynonyms(anime.title) + .addSynonyms(anime.synonyms) + .addRelatedAnime(_relatedAnime) + .addRelatedAnime(anime.relatedAnime) + .addTags(_tags) + .addTags(anime.tags) + .addStudios(_studios) + .addStudios(anime.studios) + .addProducers(_producers) + .addProducers(anime.producers) + .addScores(metaDataProviderScores.values) + .addScores(anime.scores) + } + + /** + * Performs checks and fixes data. + * @since 11.0.0 + * @return Same instance of the anime. + * @throws IllegalArgumentException if _title is blank or number of episodes is negative. + */ + public fun performChecks(): AnimeRaw { + _title = _title.normalize() + require(_title.neitherNullNorBlank()) { "Title cannot be blank." } + + require(episodes >= 0) { "Episodes cannot have a negative value." } + + val uncheckedSources: Collection<URI> = _sources.toSet() + _sources.clear() + addSources(uncheckedSources) + + val uncheckedSynonyms: Collection<Title> = _synonyms.toSet() + _synonyms.clear() + addSynonyms(uncheckedSynonyms) + + val uncheckedStudios: Collection<Title> = _studios.toSet() + _studios.clear() + addStudios(uncheckedStudios) + + val uncheckedProducers: Collection<Title> = _producers.toSet() + _producers.clear() + addProducers(uncheckedProducers) + + val uncheckedRelatedAnime: Collection<RelatedAnime> = _relatedAnime.toSet() + _relatedAnime.clear() + addRelatedAnime(uncheckedRelatedAnime) + + val uncheckedTags: Collection<Tag> = _tags.toList() + _tags.clear() + addTags(uncheckedTags) + + return this + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AnimeRaw + + if (_title != other._title) return false + if (_sources != other.sources) return false + if (type != other.type) return false + if (episodes != other.episodes) return false + if (status != other.status) return false + if (animeSeason != other.animeSeason) return false + if (picture != other.picture) return false + if (thumbnail != other.thumbnail) return false + if (duration != other.duration) return false + if (scores != other.scores) return false + if (_synonyms != other.synonyms) return false + if (_studios != other.studios) return false + if (_producers != other.producers) return false + if (_relatedAnime != other.relatedAnime) return false + if (_tags != other.tags) return false + + return true + } + + override fun hashCode(): Int { + var result = _title.hashCode() + result = 31 * result + _sources.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + episodes + result = 31 * result + status.hashCode() + result = 31 * result + animeSeason.hashCode() + result = 31 * result + picture.hashCode() + result = 31 * result + thumbnail.hashCode() + result = 31 * result + duration.hashCode() + result = 31 * result + metaDataProviderScores.values.sorted().hashCode() + result = 31 * result + _synonyms.hashCode() + result = 31 * result + _studios.hashCode() + result = 31 * result + _producers.hashCode() + result = 31 * result + _relatedAnime.hashCode() + result = 31 * result + _tags.hashCode() + return result + } + + override fun toString(): String { + return """ + AnimeRaw( + sources = ${_sources.sorted()} + title = $_title + type = $type + episodes = $episodes + status = $status + animeSeason = $animeSeason + picture = $picture + thumbnail = $thumbnail + duration = $duration + scores = ${metaDataProviderScores.values.sortedBy { it.hostname }} + synonyms = ${_synonyms.sorted()} + studios = ${_studios.sorted()} + producers = ${_producers.sorted()} + relatedAnime = ${_relatedAnime.sorted()} + tags = ${_tags.sorted()} + ) + """.trimIndent() + } + + private fun removeOverlappingDuplicates(list: Collection<String>): Collection<String> { + if (list.isEmpty() || list.size == 1) { + return list + } + + val ret = ConcurrentHashMap.newKeySet<String>(list.size).apply { addAll(list) } + var lastSize: Int + + do { + lastSize = ret.size + + for (outerName in ret) { + val last = outerName.split(' ').lastOrNull() ?: continue + + for (innerName in ret) { + val first = innerName.split(' ').firstOrNull() ?: continue + + if (last == first && outerName != innerName && last !in falsePositives) { + when { + outerName.length > innerName.length -> ret.remove(innerName) + else -> ret.remove(outerName) + } + } + } + } + } while (lastSize > ret.size) + + return ret + } + + private fun normalizeApostrophes(value: String, normalizeTo: Char = SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY): String { + var normalized = value + + val apostropheAsSuffix = when { + normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX + else -> APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX + } + + if (apostropheAsSuffix.containsMatchIn(normalized)) { + apostropheAsSuffix.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> + normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) + } + } + + val apostropheUsage = when { + normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_USAGE_INCLUDING_U2019_REGEX + else -> APOSTROPH_USAGE_EXCEPT_U2019_REGEX + } + + if (apostropheUsage.containsMatchIn(normalized)) { + apostropheUsage.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> + normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) + } + } + + return normalized + } + + private companion object { + private const val CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY: Char = '\u2019' + private const val SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY: Char = '\u0027' + private val APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() + private val APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() + private val APOSTROPH_USAGE_EXCEPT_U2019_REGEX: Regex = """(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() + private val APOSTROPH_USAGE_INCLUDING_U2019_REGEX: Regex = """(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() + private val falsePositives = hashSetOf<String>( + "anime", + "studio", + "tv", + "tokyo", + "groove", + "media", + "production", + "animation", + "television", + "group", + ) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnime.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnime.kt new file mode 100644 index 00000000..1f9647b9 --- /dev/null +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnime.kt @@ -0,0 +1,47 @@ +package io.github.manamiproject.modb.core.anime + +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import io.github.manamiproject.modb.core.extensions.normalize +import java.net.URI + +/** + * Kind of relation between two anime exactly as the metadata provider labels it. + * Examples: `Sequel` (myanimelist), `SIDE_STORY` (anilist), `Side Story` (anidb). + * The labels are not unified across metadata providers. + * @since 20.0.0 + */ +public typealias RelationType = String + +/** + * Link to a related anime together with the kind of relation. + * Use [RelatedAnime.of] to create instances from raw data, because it normalizes the label. + * @since 20.0.0 + * @property uri Link to the related anime on the metadata provider. + * @property relation Kind of relation as labelled by the metadata provider (see [RelationType]). + * `null` if the metadata provider doesn't name the relation. + */ +public data class RelatedAnime( + val uri: URI, + val relation: RelationType? = null, +) : Comparable<RelatedAnime> { + + override fun compareTo(other: RelatedAnime): Int = compareValuesBy(this, other, { it.uri }, { it.relation }) + + override fun toString(): String = if (relation == null) uri.toString() else "$uri ($relation)" + + public companion object { + + /** + * Creates an instance with a normalized [relation]: line breaks, tabs and special whitespaces are replaced and + * the label is trimmed. Blank labels result in `null`. + * @since 20.0.0 + * @param uri Link to the related anime on the metadata provider. + * @param relation Kind of relation as labelled by the metadata provider. + * @return New instance. + */ + public fun of(uri: URI, relation: String? = null): RelatedAnime { + val normalizedRelation = relation?.normalize()?.trim()?.takeIf { it.neitherNullNorBlank() } + return RelatedAnime(uri, normalizedRelation) + } + } +} diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt index c5258a72..0a242a2f 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt @@ -1,123 +1,124 @@ -package io.github.manamiproject.modb.core.extractor - -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank - - -internal class Descendant(private val initial: String) { - - /** - * First part up to the first child element if it exists. - */ - private val selfPath: String = initial.trimStart('/').substringBefore('/').ifBlank { - initial.trimStart('/') - } - - val terminatingChild: String by lazy { - initial.removePrefix("/") - .removePrefix("/") - .removePrefix(selfPath) - .removePrefix("/") - .split('/') - .last() - } - - fun hasTerminatingChild(): Boolean = terminatingChild.neitherNullNorBlank() && (terminatingChild in setOf("text()", "node()") || terminatingChild.startsWith('@')) - - fun isTerminatingChild(): Boolean = selfPath.neitherNullNorBlank() && (selfPath in setOf("text()", "node()") || selfPath.startsWith('@')) - - private fun allFilters(value: String): List<String> { - return ALL_FILTERS.findAll(value) - .map { it.value } - .toList() - } - - private fun transformFilters(value: String): String { - var ret = value - - allFilters(ret).forEach { - val replacement = when { - FILTER_ATTRIBUTE_EQUALS_VALUE.matches(it) -> { // [@attr='value'] - val matchResult = FILTER_ATTRIBUTE_EQUALS_VALUE.matchEntire(it)!! - "[${matchResult.groups["attr"]!!.value}=${matchResult.groups["value"]!!.value}]" - } - - FILTER_ATTRIBUTE_CONTAINS.matches(it) -> { // [contains(@attr, 'value')] - val matchResult = FILTER_ATTRIBUTE_CONTAINS.matchEntire(it)!! - "[${matchResult.groups["attr"]!!.value}*=${matchResult.groups["value"]!!.value}]" - } - - FILTER_TEXT_CONTAINS.matches(it) -> { // [contains(text(), 'value')] - val matchResult = FILTER_TEXT_CONTAINS.matchEntire(it)!! - ":matchesOwn(${matchResult.groups["value"]!!.value})" - } - - FILTER_TEXT_EQUALS.matches(it) -> { // [text()='value'] - val matchResult = FILTER_TEXT_EQUALS.matchEntire(it)!! - ":matchesOwn(^${matchResult.groups["value"]!!.value}$)" - } - - FILTER_SELECT_SIBLING.matches(it) -> { // [1] - val matchResult = FILTER_SELECT_SIBLING.matchEntire(it)!! - ":eq(${matchResult.groups["number"]!!.value})" - } - - FILTER_ATTRIBUTE_EXISTS.matches(it) -> { // [@attr] - val matchResult = FILTER_ATTRIBUTE_EXISTS.matchEntire(it)!! - "[${matchResult.groups["attr"]!!.value}]" - } - - else -> throw IllegalStateException("No transformation for [$it]") - } - - ret = ret.replace(it, replacement) - } - - return ret - } - - fun toJsoup(): String { - var converted = initial.removePrefix("/") - .removePrefix("/") - - if (hasTerminatingChild()) { - converted = initial.removePrefix("/") - .removePrefix("/") - .split('/') - .toMutableList() - .apply { removeLast() } - .joinToString("/") - } - - val replacements = STRING_VALUES.findAll(converted) - .map { it.groups } - .map { it["stringValue"] } - .filterNotNull() - .mapIndexed { index, matchGroup -> - "'#|STRVAL$index|#'" to "'${matchGroup.value}'" - } - .toList() - - converted = replacements.fold(converted) { acc, pair -> - acc.replace(pair.second, pair.first) - }.replace("/", " > ") - - replacements.toMap().forEach { (replacement, original) -> - converted = converted.replace(replacement, original) - } - - converted = transformFilters(converted).replace("> following-sibling::", "~ ") - - return converted - } - - private companion object { - private val ALL_FILTERS = """\[(.*?)\]""".toRegex() - private val FILTER_ATTRIBUTE_EQUALS_VALUE = """^\[@(?<attr>[\w-]+) *= *'(?<value>.*?)'\]$""".toRegex() - private val FILTER_ATTRIBUTE_CONTAINS = """^\[contains\(@(?<attr>[\w-]+) *, *'(?<value>.*?)'\)\]$""".toRegex() - private val FILTER_TEXT_CONTAINS = """^\[contains\(text\(\) *, *'(?<value>.*?)'\)\]$""".toRegex() - private val FILTER_TEXT_EQUALS = """^\[text\(\) *= *'(?<value>.*?)'\]$""".toRegex() - private val FILTER_SELECT_SIBLING = """^\[(?<number>\d+)\]$""".toRegex() - private val FILTER_ATTRIBUTE_EXISTS = """^\[@(?<attr>[\w-]+)\]$""".toRegex() - private val STRING_VALUES = """.*?'(?<stringValue>.*?)'.*?""".toRegex() - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.extractor + +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank + + +internal class Descendant(private val initial: String) { + + /** + * First part up to the first child element if it exists. + */ + private val selfPath: String = initial.trimStart('/').substringBefore('/').ifBlank { + initial.trimStart('/') + } + + val terminatingChild: String by lazy { + initial.removePrefix("/") + .removePrefix("/") + .removePrefix(selfPath) + .removePrefix("/") + .split('/') + .last() + } + + fun hasTerminatingChild(): Boolean = terminatingChild.neitherNullNorBlank() && (terminatingChild in TERMINATING_FUNCTIONS || terminatingChild.startsWith('@')) + + fun isTerminatingChild(): Boolean = selfPath.neitherNullNorBlank() && (selfPath in TERMINATING_FUNCTIONS || selfPath.startsWith('@')) + + private fun allFilters(value: String): List<String> { + return ALL_FILTERS.findAll(value) + .map { it.value } + .toList() + } + + private fun transformFilters(value: String): String { + var ret = value + + allFilters(ret).forEach { + val replacement = when { + FILTER_ATTRIBUTE_EQUALS_VALUE.matches(it) -> { // [@attr='value'] + val matchResult = FILTER_ATTRIBUTE_EQUALS_VALUE.matchEntire(it)!! + "[${matchResult.groups["attr"]!!.value}=${matchResult.groups["value"]!!.value}]" + } + + FILTER_ATTRIBUTE_CONTAINS.matches(it) -> { // [contains(@attr, 'value')] + val matchResult = FILTER_ATTRIBUTE_CONTAINS.matchEntire(it)!! + "[${matchResult.groups["attr"]!!.value}*=${matchResult.groups["value"]!!.value}]" + } + + FILTER_TEXT_CONTAINS.matches(it) -> { // [contains(text(), 'value')] + val matchResult = FILTER_TEXT_CONTAINS.matchEntire(it)!! + ":matchesOwn(${matchResult.groups["value"]!!.value})" + } + + FILTER_TEXT_EQUALS.matches(it) -> { // [text()='value'] + val matchResult = FILTER_TEXT_EQUALS.matchEntire(it)!! + ":matchesOwn(^${matchResult.groups["value"]!!.value}$)" + } + + FILTER_SELECT_SIBLING.matches(it) -> { // [1] + val matchResult = FILTER_SELECT_SIBLING.matchEntire(it)!! + ":eq(${matchResult.groups["number"]!!.value})" + } + + FILTER_ATTRIBUTE_EXISTS.matches(it) -> { // [@attr] + val matchResult = FILTER_ATTRIBUTE_EXISTS.matchEntire(it)!! + "[${matchResult.groups["attr"]!!.value}]" + } + + else -> throw IllegalStateException("No transformation for [$it]") + } + + ret = ret.replace(it, replacement) + } + + return ret + } + + fun toJsoup(): String { + var converted = initial.removePrefix("/") + .removePrefix("/") + + if (hasTerminatingChild()) { + converted = initial.removePrefix("/") + .removePrefix("/") + .split('/') + .toMutableList() + .apply { removeLast() } + .joinToString("/") + } + + val replacements = STRING_VALUES.findAll(converted) + .map { it.groups } + .map { it["stringValue"] } + .filterNotNull() + .mapIndexed { index, matchGroup -> + "'#|STRVAL$index|#'" to "'${matchGroup.value}'" + } + .toList() + + converted = replacements.fold(converted) { acc, pair -> + acc.replace(pair.second, pair.first) + }.replace("/", " > ") + + replacements.toMap().forEach { (replacement, original) -> + converted = converted.replace(replacement, original) + } + + converted = transformFilters(converted).replace("> following-sibling::", "~ ") + + return converted + } + + private companion object { + private val TERMINATING_FUNCTIONS = setOf("text()", "node()", "html()") + private val ALL_FILTERS = """\[(.*?)\]""".toRegex() + private val FILTER_ATTRIBUTE_EQUALS_VALUE = """^\[@(?<attr>[\w-]+) *= *'(?<value>.*?)'\]$""".toRegex() + private val FILTER_ATTRIBUTE_CONTAINS = """^\[contains\(@(?<attr>[\w-]+) *, *'(?<value>.*?)'\)\]$""".toRegex() + private val FILTER_TEXT_CONTAINS = """^\[contains\(text\(\) *, *'(?<value>.*?)'\)\]$""".toRegex() + private val FILTER_TEXT_EQUALS = """^\[text\(\) *= *'(?<value>.*?)'\]$""".toRegex() + private val FILTER_SELECT_SIBLING = """^\[(?<number>\d+)\]$""".toRegex() + private val FILTER_ATTRIBUTE_EXISTS = """^\[@(?<attr>[\w-]+)\]$""".toRegex() + private val STRING_VALUES = """.*?'(?<stringValue>.*?)'.*?""".toRegex() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupCssSelectorDataExtractor.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupCssSelectorDataExtractor.kt index 801ec705..39b99a5d 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupCssSelectorDataExtractor.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupCssSelectorDataExtractor.kt @@ -113,6 +113,7 @@ internal object JsoupCssSelectorDataExtractor : DataExtractor { jsoupElements.isEmpty() -> NotFound terminatingChild == "text()" -> jsoupElements.eachText() terminatingChild == "node()" -> jsoupElements.dataNodes().map { it.wholeData.trim() } + terminatingChild == "html()" -> jsoupElements.map { it.outerHtml() } terminatingChild.eitherNullOrBlank() -> jsoupElements.textNodes().map { it.text().trim() }.filter { it.neitherNullNorBlank() } terminatingChild.startsWith('@') -> jsoupElements.eachAttr(terminatingChild.trimStart('@')) ?: NotFound else -> throw IllegalStateException("unmapped case") diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt index e3e40952..8ade5665 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt @@ -1,65 +1,67 @@ -package io.github.manamiproject.modb.core.extractor - -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import kotlin.reflect.full.isSubclassOf - -internal object JsoupXPathDataExtractor : DataExtractor { - - override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult { - val document = Jsoup.parse(rawContent) - - val result: Map<OutputKey, Any> = selection.map { transformXpathToTriple(it) } - .map { elementSelection(document, it) } - .associate { toOutput(it) } - - return ExtractionResult(result) - } - - private fun toOutput(item: Pair<OutputKey, Any>) : Pair<OutputKey, Any> { - if (item.second::class.isSubclassOf(Iterable::class)) { - val castedList = (item.second as Iterable<*>).toList() - - return if (castedList.size == 1) { - item.first to castedList.first()!! - } else { - item.first to castedList - } - } else { - return item - } - } - - private fun transformXpathToTriple(entry: Map.Entry<OutputKey, Selector>): Triple<OutputKey, Selector, String> { - val split = entry.value.split('/') - val allExceptLast = split.subList(0, split.size-1).joinToString("/") - - return when { - split.last().startsWith("@") -> Triple(entry.key, allExceptLast, split.last().trimStart('@')) - split.last().endsWith("text()") -> Triple(entry.key, allExceptLast, split.last()) - split.last().endsWith("node()") -> Triple(entry.key, allExceptLast, split.last()) - else -> Triple(entry.key, entry.value, EMPTY) - } - } - - private fun elementSelection(document: Document, entry: Triple<OutputKey, Selector, String>): Pair<OutputKey, Any> { - val jsoupElements = if (entry.second.neitherNullNorBlank()) { - document.selectXpath(entry.second) - } else { - document.allElements - } - - val value = when { - jsoupElements.isEmpty() -> NotFound - entry.third == "text()" -> jsoupElements.eachText() ?: NotFound - entry.third == "node()" -> jsoupElements.dataNodes().map { it.wholeData.trim() } - entry.third.eitherNullOrBlank() -> jsoupElements.textNodes().map { it.text().trim() }.filter { it.neitherNullNorBlank() } - else -> jsoupElements.eachAttr(entry.third) ?: NotFound - } - - return entry.first to value - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.extractor + +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import kotlin.reflect.full.isSubclassOf + +internal object JsoupXPathDataExtractor : DataExtractor { + + override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult { + val document = Jsoup.parse(rawContent) + + val result: Map<OutputKey, Any> = selection.map { transformXpathToTriple(it) } + .map { elementSelection(document, it) } + .associate { toOutput(it) } + + return ExtractionResult(result) + } + + private fun toOutput(item: Pair<OutputKey, Any>) : Pair<OutputKey, Any> { + if (item.second::class.isSubclassOf(Iterable::class)) { + val castedList = (item.second as Iterable<*>).toList() + + return if (castedList.size == 1) { + item.first to castedList.first()!! + } else { + item.first to castedList + } + } else { + return item + } + } + + private fun transformXpathToTriple(entry: Map.Entry<OutputKey, Selector>): Triple<OutputKey, Selector, String> { + val split = entry.value.split('/') + val allExceptLast = split.subList(0, split.size-1).joinToString("/") + + return when { + split.last().startsWith("@") -> Triple(entry.key, allExceptLast, split.last().trimStart('@')) + split.last().endsWith("text()") -> Triple(entry.key, allExceptLast, split.last()) + split.last().endsWith("node()") -> Triple(entry.key, allExceptLast, split.last()) + split.last().endsWith("html()") -> Triple(entry.key, allExceptLast, split.last()) + else -> Triple(entry.key, entry.value, EMPTY) + } + } + + private fun elementSelection(document: Document, entry: Triple<OutputKey, Selector, String>): Pair<OutputKey, Any> { + val jsoupElements = if (entry.second.neitherNullNorBlank()) { + document.selectXpath(entry.second) + } else { + document.allElements + } + + val value = when { + jsoupElements.isEmpty() -> NotFound + entry.third == "text()" -> jsoupElements.eachText() ?: NotFound + entry.third == "node()" -> jsoupElements.dataNodes().map { it.wholeData.trim() } + entry.third == "html()" -> jsoupElements.map { it.outerHtml() } + entry.third.eitherNullOrBlank() -> jsoupElements.textNodes().map { it.text().trim() }.filter { it.neitherNullNorBlank() } + else -> jsoupElements.eachAttr(entry.third) ?: NotFound + } + + return entry.first to value + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinks.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinks.kt new file mode 100644 index 00000000..1ab05fad --- /dev/null +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinks.kt @@ -0,0 +1,57 @@ +package io.github.manamiproject.modb.core.extractor + +/** + * Links which belong together under a common label. Used to read related anime together with the kind of relation. + * @since 20.0.0 + * @property label The label as it appears in the raw content. `null` if there is none. + * @property links Raw values of the links (usually `href` attributes) in the order of the raw content. + */ +public data class LabelledLinks( + val label: String?, + val links: List<String>, +) + +/** + * Extracts a label and the links belonging to it from each of the given HTML snippets. + * The snippets are usually the result of a selector ending with `html()`, one snippet per entry or group of entries. + * @since 20.0.0 + * @param snippets HTML snippets. Each one is processed on its own. + * @param labelSelector XPath selecting the label within a snippet. If it matches multiple times, the first match is used. + * @param linkSelector XPath selecting the links within a snippet. + * @param wrap Transforms a snippet before it is parsed. Table rows for example must be wrapped in `<table>` to be parsed correctly. + * @return One [LabelledLinks] per snippet which contains at least one link. + */ +public suspend fun DataExtractor.extractLabelledLinks( + snippets: List<String>, + labelSelector: Selector, + linkSelector: Selector, + wrap: (String) -> String = { it }, +): List<LabelledLinks> { + return snippets.mapNotNull { snippet -> + val data = extract(wrap(snippet), mapOf( + "label" to labelSelector, + "links" to linkSelector, + )) + + if (data.notFound("links")) { + return@mapNotNull null + } + + val label = if (data.notFound("label")) { + null + } else { + data.listNotNull<String>("label").firstOrNull() + } + + LabelledLinks( + label = label, + links = data.listNotNull<String>("links"), + ) + } +} + +/** + * Wraps a table row (`<tr>`) in a table so that it is parsed as a row instead of being dropped by the HTML parser. + * @since 20.0.0 + */ +public val WRAP_TABLE_ROW: (String) -> String = { "<table>$it</table>" } diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt index 6f2986f3..12defd85 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt @@ -1,218 +1,219 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.* -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.extensions.EMPTY -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeAdapter: JsonAdapter<Anime>() { - - private val titleAdapter = TitleAdapter() - private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) - private val uriAdapter = UriAdapter() - private val typeAdapter = AnimeTypeAdapter() - private val statusAdapter = AnimeStatusAdapter() - private val animeSeasonAdapter = AnimeSeasonAdapter() - private val durationAdapter = DurationAdapter() - private val scoreAdapter = ScoreAdapter() - private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) - private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) - private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) - private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) - - @FromJson - override fun fromJson(reader: JsonReader): Anime { - reader.beginObject() - - var title = EMPTY - var titleDeserialized = false - var sources = HashSet<URI>() - var sourcesDeserialized = false - var type = UNKNOWN_TYPE - var typeDeserialized = false - var episodes = 0 - var episodesDeserialized = false - var status = UNKNOWN_STATUS - var statusDeserialized = false - var animeSeason = AnimeSeason() - var animeSeasonDeserialized = false - var picture = NO_PICTURE - var pictureDeserialized = false - var thumbnail = NO_PICTURE_THUMBNAIL - var thumbnailDeserialized = false - var duration = UNKNOWN_DURATION - var score: Score = NoScore - var synonyms = HashSet<Title>() - var synonymsDeserialized = false - var studios = HashSet<String>() - var studiosDeserialized = false - var producers = HashSet<String>() - var producersDeserialized = false - var relatedAnime = HashSet<URI>() - var relatedAnimeDeserialized = false - var tags = HashSet<Tag>() - var tagsDeserialized = false - - while (reader.hasNext()) { - when (reader.nextName()) { - "sources" -> { - sources = uriHashSetAdapter.fromJson(reader) - sourcesDeserialized = true - } - "title" -> { - title = titleAdapter.fromJson(reader) - titleDeserialized = true - } - "type" -> { - type = typeAdapter.fromJson(reader) - typeDeserialized = true - } - "episodes" -> { - episodes = reader.nextInt() - episodesDeserialized = true - } - "status" -> { - status = statusAdapter.fromJson(reader) - statusDeserialized = true - } - "animeSeason" -> { - animeSeason = animeSeasonAdapter.fromJson(reader) - animeSeasonDeserialized = true - } - "picture" -> { - picture = uriAdapter.fromJson(reader) - pictureDeserialized = true - } - "thumbnail" -> { - thumbnail = uriAdapter.fromJson(reader) - thumbnailDeserialized = true - } - "duration" -> { - duration = durationAdapter.fromJson(reader) - } - "score" -> { - score = scoreAdapter.fromJson(reader) - } - "synonyms" -> { - synonyms = titleHashSetAdapter.fromJson(reader) - synonymsDeserialized = true - } - "studios" -> { - studios = studioHashSetAdapter.fromJson(reader) - studiosDeserialized = true - } - "producers" -> { - producers = producerHashSetAdapter.fromJson(reader) - producersDeserialized = true - } - "relatedAnime" -> { - relatedAnime = uriHashSetAdapter.fromJson(reader) - relatedAnimeDeserialized = true - } - "tags" -> { - tags = tagHashSetAdapter.fromJson(reader) - tagsDeserialized = true - } - else -> reader.skipValue() - } - } - - reader.endObject() - - when { - !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") - !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") - !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") - !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") - !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") - !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") - !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") - !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") - !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") - !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") - !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") - !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") - !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") - } - - return Anime( - title = title, - sources = sources, - type = type, - episodes = episodes, - status = status, - animeSeason = animeSeason, - picture = picture, - thumbnail = thumbnail, - duration = duration, - score = score, - synonyms = synonyms, - studios = studios, - producers = producers, - relatedAnime = relatedAnime, - tags = tags, - ) - } - - @ToJson - override fun toJson(writer: JsonWriter, value: Anime?) { - requireNotNull(value) { "AnimeAdapter expects non-nullable value, but received null." } - - writer.beginObject() - - writer.name("sources") - uriHashSetAdapter.toJson(writer, value.sources) - - writer.name("title") - titleAdapter.toJson(writer, value.title) - - writer.name("type") - typeAdapter.toJson(writer, value.type) - - writer.name("episodes").value(value.episodes) - - writer.name("status") - statusAdapter.toJson(writer, value.status) - - writer.name("animeSeason") - animeSeasonAdapter.toJson(writer, value.animeSeason) - - writer.name("picture") - uriAdapter.toJson(writer, value.picture) - - writer.name("thumbnail") - uriAdapter.toJson(writer, value.thumbnail) - - if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { - writer.name("duration") - durationAdapter.toJson(writer, value.duration) - } - - if (value.score != NoScore || (value.score == NoScore && writer.serializeNulls)) { - writer.name("score") - scoreAdapter.toJson(writer, value.score) - } - - writer.name("synonyms") - titleHashSetAdapter.toJson(writer, value.synonyms) - - writer.name("studios") - studioHashSetAdapter.toJson(writer, value.studios) - - writer.name("producers") - producerHashSetAdapter.toJson(writer, value.producers) - - writer.name("relatedAnime") - uriHashSetAdapter.toJson(writer, value.relatedAnime) - - writer.name("tags") - titleHashSetAdapter.toJson(writer, value.tags) - - writer.endObject() - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.* +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.extensions.EMPTY +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeAdapter: JsonAdapter<Anime>() { + + private val titleAdapter = TitleAdapter() + private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) + private val uriAdapter = UriAdapter() + private val typeAdapter = AnimeTypeAdapter() + private val statusAdapter = AnimeStatusAdapter() + private val animeSeasonAdapter = AnimeSeasonAdapter() + private val durationAdapter = DurationAdapter() + private val scoreAdapter = ScoreAdapter() + private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) + private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) + private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) + private val relatedAnimeHashSetAdapter = HashSetAdapter(RelatedAnimeAdapter()) + private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) + + @FromJson + override fun fromJson(reader: JsonReader): Anime { + reader.beginObject() + + var title = EMPTY + var titleDeserialized = false + var sources = HashSet<URI>() + var sourcesDeserialized = false + var type = UNKNOWN_TYPE + var typeDeserialized = false + var episodes = 0 + var episodesDeserialized = false + var status = UNKNOWN_STATUS + var statusDeserialized = false + var animeSeason = AnimeSeason() + var animeSeasonDeserialized = false + var picture = NO_PICTURE + var pictureDeserialized = false + var thumbnail = NO_PICTURE_THUMBNAIL + var thumbnailDeserialized = false + var duration = UNKNOWN_DURATION + var score: Score = NoScore + var synonyms = HashSet<Title>() + var synonymsDeserialized = false + var studios = HashSet<String>() + var studiosDeserialized = false + var producers = HashSet<String>() + var producersDeserialized = false + var relatedAnime = HashSet<RelatedAnime>() + var relatedAnimeDeserialized = false + var tags = HashSet<Tag>() + var tagsDeserialized = false + + while (reader.hasNext()) { + when (reader.nextName()) { + "sources" -> { + sources = uriHashSetAdapter.fromJson(reader) + sourcesDeserialized = true + } + "title" -> { + title = titleAdapter.fromJson(reader) + titleDeserialized = true + } + "type" -> { + type = typeAdapter.fromJson(reader) + typeDeserialized = true + } + "episodes" -> { + episodes = reader.nextInt() + episodesDeserialized = true + } + "status" -> { + status = statusAdapter.fromJson(reader) + statusDeserialized = true + } + "animeSeason" -> { + animeSeason = animeSeasonAdapter.fromJson(reader) + animeSeasonDeserialized = true + } + "picture" -> { + picture = uriAdapter.fromJson(reader) + pictureDeserialized = true + } + "thumbnail" -> { + thumbnail = uriAdapter.fromJson(reader) + thumbnailDeserialized = true + } + "duration" -> { + duration = durationAdapter.fromJson(reader) + } + "score" -> { + score = scoreAdapter.fromJson(reader) + } + "synonyms" -> { + synonyms = titleHashSetAdapter.fromJson(reader) + synonymsDeserialized = true + } + "studios" -> { + studios = studioHashSetAdapter.fromJson(reader) + studiosDeserialized = true + } + "producers" -> { + producers = producerHashSetAdapter.fromJson(reader) + producersDeserialized = true + } + "relatedAnime" -> { + relatedAnime = relatedAnimeHashSetAdapter.fromJson(reader) + relatedAnimeDeserialized = true + } + "tags" -> { + tags = tagHashSetAdapter.fromJson(reader) + tagsDeserialized = true + } + else -> reader.skipValue() + } + } + + reader.endObject() + + when { + !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") + !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") + !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") + !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") + !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") + !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") + !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") + !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") + !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") + !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") + !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") + !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") + !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") + } + + return Anime( + title = title, + sources = sources, + type = type, + episodes = episodes, + status = status, + animeSeason = animeSeason, + picture = picture, + thumbnail = thumbnail, + duration = duration, + score = score, + synonyms = synonyms, + studios = studios, + producers = producers, + relatedAnime = relatedAnime, + tags = tags, + ) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: Anime?) { + requireNotNull(value) { "AnimeAdapter expects non-nullable value, but received null." } + + writer.beginObject() + + writer.name("sources") + uriHashSetAdapter.toJson(writer, value.sources) + + writer.name("title") + titleAdapter.toJson(writer, value.title) + + writer.name("type") + typeAdapter.toJson(writer, value.type) + + writer.name("episodes").value(value.episodes) + + writer.name("status") + statusAdapter.toJson(writer, value.status) + + writer.name("animeSeason") + animeSeasonAdapter.toJson(writer, value.animeSeason) + + writer.name("picture") + uriAdapter.toJson(writer, value.picture) + + writer.name("thumbnail") + uriAdapter.toJson(writer, value.thumbnail) + + if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { + writer.name("duration") + durationAdapter.toJson(writer, value.duration) + } + + if (value.score != NoScore || (value.score == NoScore && writer.serializeNulls)) { + writer.name("score") + scoreAdapter.toJson(writer, value.score) + } + + writer.name("synonyms") + titleHashSetAdapter.toJson(writer, value.synonyms) + + writer.name("studios") + studioHashSetAdapter.toJson(writer, value.studios) + + writer.name("producers") + producerHashSetAdapter.toJson(writer, value.producers) + + writer.name("relatedAnime") + relatedAnimeHashSetAdapter.toJson(writer, value.relatedAnime) + + writer.name("tags") + titleHashSetAdapter.toJson(writer, value.tags) + + writer.endObject() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt index 87ed0851..ddaa7887 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt @@ -1,220 +1,221 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.* -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.extensions.EMPTY -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeRawAdapter: JsonAdapter<AnimeRaw>() { - - private val titleAdapter = TitleAdapter() - private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) - private val uriAdapter = UriAdapter() - private val typeAdapter = AnimeTypeAdapter() - private val statusAdapter = AnimeStatusAdapter() - private val animeSeasonAdapter = AnimeSeasonAdapter() - private val durationAdapter = DurationAdapter() - private val metaDataProviderScoreValueAdapter = HashSetAdapter(MetaDataProviderScoreValueAdapter()) - private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) - private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) - private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) - private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) - - @FromJson - override fun fromJson(reader: JsonReader): AnimeRaw { - reader.beginObject() - - var title = EMPTY - var titleDeserialized = false - var sources = HashSet<URI>() - var sourcesDeserialized = false - var type = UNKNOWN_TYPE - var typeDeserialized = false - var episodes = 0 - var episodesDeserialized = false - var status = UNKNOWN_STATUS - var statusDeserialized = false - var animeSeason = AnimeSeason() - var animeSeasonDeserialized = false - var picture = NO_PICTURE - var pictureDeserialized = false - var thumbnail = NO_PICTURE_THUMBNAIL - var thumbnailDeserialized = false - var duration = UNKNOWN_DURATION - var scores = HashSet<MetaDataProviderScoreValue>() - var synonyms = HashSet<Title>() - var synonymsDeserialized = false - var studios = HashSet<String>() - var studiosDeserialized = false - var producers = HashSet<String>() - var producersDeserialized = false - var relatedAnime = HashSet<URI>() - var relatedAnimeDeserialized = false - var tags = HashSet<Tag>() - var tagsDeserialized = false - - while (reader.hasNext()) { - when (reader.nextName()) { - "sources" -> { - sources = uriHashSetAdapter.fromJson(reader) - sourcesDeserialized = true - } - "title" -> { - title = titleAdapter.fromJson(reader) - titleDeserialized = true - } - "type" -> { - type = typeAdapter.fromJson(reader) - typeDeserialized = true - } - "episodes" -> { - episodes = reader.nextInt() - episodesDeserialized = true - } - "status" -> { - status = statusAdapter.fromJson(reader) - statusDeserialized = true - } - "animeSeason" -> { - animeSeason = animeSeasonAdapter.fromJson(reader) - animeSeasonDeserialized = true - } - "picture" -> { - picture = uriAdapter.fromJson(reader) - pictureDeserialized = true - } - "thumbnail" -> { - thumbnail = uriAdapter.fromJson(reader) - thumbnailDeserialized = true - } - "duration" -> { - duration = durationAdapter.fromJson(reader) - } - "scores" -> { - scores = metaDataProviderScoreValueAdapter.fromJson(reader) - } - "synonyms" -> { - synonyms = titleHashSetAdapter.fromJson(reader) - synonymsDeserialized = true - } - "studios" -> { - studios = titleHashSetAdapter.fromJson(reader) - studiosDeserialized = true - } - "producers" -> { - producers = titleHashSetAdapter.fromJson(reader) - producersDeserialized = true - } - "relatedAnime" -> { - relatedAnime = uriHashSetAdapter.fromJson(reader) - relatedAnimeDeserialized = true - } - "tags" -> { - tags = tagHashSetAdapter.fromJson(reader) - tagsDeserialized = true - } - else -> reader.skipValue() - } - } - - reader.endObject() - - when { - !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") - !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") - !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") - !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") - !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") - !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") - !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") - !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") - !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") - !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") - !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") - !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") - !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") - } - - return AnimeRaw( - _title = title, - _sources = sources, - type = type, - episodes = episodes, - status = status, - animeSeason = animeSeason, - picture = picture, - thumbnail = thumbnail, - duration = duration, - _synonyms = synonyms, - _studios = studios, - _producers = producers, - _relatedAnime = relatedAnime, - _tags = tags, - activateChecks = false, - ).addScores(scores) - } - - @ToJson - override fun toJson(writer: JsonWriter, value: AnimeRaw?) { - requireNotNull(value) { "AnimeRawAdapter expects non-nullable value, but received null." } - - if (!value.activateChecks) { - value.performChecks() - } - - writer.beginObject() - - writer.name("sources") - uriHashSetAdapter.toJson(writer, value.sources) - - writer.name("title") - titleAdapter.toJson(writer, value.title) - - writer.name("type") - typeAdapter.toJson(writer, value.type) - - writer.name("episodes").value(value.episodes) - - writer.name("status") - statusAdapter.toJson(writer, value.status) - - writer.name("animeSeason") - animeSeasonAdapter.toJson(writer, value.animeSeason) - - writer.name("picture") - uriAdapter.toJson(writer, value.picture) - - writer.name("thumbnail") - uriAdapter.toJson(writer, value.thumbnail) - - if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { - writer.name("duration") - durationAdapter.toJson(writer, value.duration) - } - - writer.name("scores") - metaDataProviderScoreValueAdapter.toJson(writer, value.scores) - - writer.name("synonyms") - titleHashSetAdapter.toJson(writer, value.synonyms) - - writer.name("studios") - studioHashSetAdapter.toJson(writer, value.studios) - - writer.name("producers") - producerHashSetAdapter.toJson(writer, value.producers) - - writer.name("relatedAnime") - uriHashSetAdapter.toJson(writer, value.relatedAnime) - - writer.name("tags") - tagHashSetAdapter.toJson(writer, value.tags) - - writer.endObject() - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.* +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.extensions.EMPTY +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeRawAdapter: JsonAdapter<AnimeRaw>() { + + private val titleAdapter = TitleAdapter() + private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) + private val uriAdapter = UriAdapter() + private val typeAdapter = AnimeTypeAdapter() + private val statusAdapter = AnimeStatusAdapter() + private val animeSeasonAdapter = AnimeSeasonAdapter() + private val durationAdapter = DurationAdapter() + private val metaDataProviderScoreValueAdapter = HashSetAdapter(MetaDataProviderScoreValueAdapter()) + private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) + private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) + private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) + private val relatedAnimeHashSetAdapter = HashSetAdapter(RelatedAnimeAdapter()) + private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) + + @FromJson + override fun fromJson(reader: JsonReader): AnimeRaw { + reader.beginObject() + + var title = EMPTY + var titleDeserialized = false + var sources = HashSet<URI>() + var sourcesDeserialized = false + var type = UNKNOWN_TYPE + var typeDeserialized = false + var episodes = 0 + var episodesDeserialized = false + var status = UNKNOWN_STATUS + var statusDeserialized = false + var animeSeason = AnimeSeason() + var animeSeasonDeserialized = false + var picture = NO_PICTURE + var pictureDeserialized = false + var thumbnail = NO_PICTURE_THUMBNAIL + var thumbnailDeserialized = false + var duration = UNKNOWN_DURATION + var scores = HashSet<MetaDataProviderScoreValue>() + var synonyms = HashSet<Title>() + var synonymsDeserialized = false + var studios = HashSet<String>() + var studiosDeserialized = false + var producers = HashSet<String>() + var producersDeserialized = false + var relatedAnime = HashSet<RelatedAnime>() + var relatedAnimeDeserialized = false + var tags = HashSet<Tag>() + var tagsDeserialized = false + + while (reader.hasNext()) { + when (reader.nextName()) { + "sources" -> { + sources = uriHashSetAdapter.fromJson(reader) + sourcesDeserialized = true + } + "title" -> { + title = titleAdapter.fromJson(reader) + titleDeserialized = true + } + "type" -> { + type = typeAdapter.fromJson(reader) + typeDeserialized = true + } + "episodes" -> { + episodes = reader.nextInt() + episodesDeserialized = true + } + "status" -> { + status = statusAdapter.fromJson(reader) + statusDeserialized = true + } + "animeSeason" -> { + animeSeason = animeSeasonAdapter.fromJson(reader) + animeSeasonDeserialized = true + } + "picture" -> { + picture = uriAdapter.fromJson(reader) + pictureDeserialized = true + } + "thumbnail" -> { + thumbnail = uriAdapter.fromJson(reader) + thumbnailDeserialized = true + } + "duration" -> { + duration = durationAdapter.fromJson(reader) + } + "scores" -> { + scores = metaDataProviderScoreValueAdapter.fromJson(reader) + } + "synonyms" -> { + synonyms = titleHashSetAdapter.fromJson(reader) + synonymsDeserialized = true + } + "studios" -> { + studios = titleHashSetAdapter.fromJson(reader) + studiosDeserialized = true + } + "producers" -> { + producers = titleHashSetAdapter.fromJson(reader) + producersDeserialized = true + } + "relatedAnime" -> { + relatedAnime = relatedAnimeHashSetAdapter.fromJson(reader) + relatedAnimeDeserialized = true + } + "tags" -> { + tags = tagHashSetAdapter.fromJson(reader) + tagsDeserialized = true + } + else -> reader.skipValue() + } + } + + reader.endObject() + + when { + !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") + !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") + !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") + !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") + !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") + !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") + !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") + !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") + !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") + !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") + !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") + !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") + !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") + } + + return AnimeRaw( + _title = title, + _sources = sources, + type = type, + episodes = episodes, + status = status, + animeSeason = animeSeason, + picture = picture, + thumbnail = thumbnail, + duration = duration, + _synonyms = synonyms, + _studios = studios, + _producers = producers, + _relatedAnime = relatedAnime, + _tags = tags, + activateChecks = false, + ).addScores(scores) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: AnimeRaw?) { + requireNotNull(value) { "AnimeRawAdapter expects non-nullable value, but received null." } + + if (!value.activateChecks) { + value.performChecks() + } + + writer.beginObject() + + writer.name("sources") + uriHashSetAdapter.toJson(writer, value.sources) + + writer.name("title") + titleAdapter.toJson(writer, value.title) + + writer.name("type") + typeAdapter.toJson(writer, value.type) + + writer.name("episodes").value(value.episodes) + + writer.name("status") + statusAdapter.toJson(writer, value.status) + + writer.name("animeSeason") + animeSeasonAdapter.toJson(writer, value.animeSeason) + + writer.name("picture") + uriAdapter.toJson(writer, value.picture) + + writer.name("thumbnail") + uriAdapter.toJson(writer, value.thumbnail) + + if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { + writer.name("duration") + durationAdapter.toJson(writer, value.duration) + } + + writer.name("scores") + metaDataProviderScoreValueAdapter.toJson(writer, value.scores) + + writer.name("synonyms") + titleHashSetAdapter.toJson(writer, value.synonyms) + + writer.name("studios") + studioHashSetAdapter.toJson(writer, value.studios) + + writer.name("producers") + producerHashSetAdapter.toJson(writer, value.producers) + + writer.name("relatedAnime") + relatedAnimeHashSetAdapter.toJson(writer, value.relatedAnime) + + writer.name("tags") + tagHashSetAdapter.toJson(writer, value.tags) + + writer.endObject() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt index f3335cb1..8b6d6784 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt @@ -1,117 +1,118 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.Moshi -import com.squareup.moshi.adapter -import com.squareup.moshi.addAdapter -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_FS -import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL -import kotlinx.coroutines.withContext -import okio.buffer -import okio.source -import com.squareup.moshi.JsonAdapter as MoshiAdapter - -/** - * Handles serialization and deserialization of objects to/from JSON. - * @since 11.0.0 - */ -public object Json { - - private const val JSON_IDENT: String = " " - - @PublishedApi - @OptIn(ExperimentalStdlibApi::class) - internal val moshi: Moshi = Moshi.Builder() - .addAdapter(UriAdapter()) - .addAdapter(DurationAdapter()) - .addAdapter(AnimeTypeAdapter()) - .addAdapter(AnimeStatusAdapter()) - .addAdapter(AnimeSeasonAdapter()) - .addAdapter(AnimeRawAdapter()) - .addAdapter(AnimeAdapter()) - .addAdapter(ScoreAdapter()) - .addAdapter(MetaDataProviderScoreValueAdapter()) - .addLast(KotlinJsonAdapterFactory()) - .build() - - /** - * Parse a [String] into an object. - * - * **WARNING** [Collection]s of a non-nullable type can still contain null. - * @since 8.0.0 - * @param json Valid JSON as [String]. - * @return Deserialized JSON as object of given type [T]. - */ - @OptIn(ExperimentalStdlibApi::class) - public suspend inline fun <reified T> parseJson(json: String): T? = withContext(LIMITED_CPU) { - return@withContext moshi.adapter<T>().nullSafe().fromJson(json) - } - - /** - * Parse an [LifecycleAwareInputStream] into an object. - * - * **WARNING** [Collection]s of a non-nullable type can still contain null. - * @since 8.0.0 - * @param json Valid JSON as [LifecycleAwareInputStream]. - * @return Deserialized JSON as object of given type [T]. - */ - @OptIn(ExperimentalStdlibApi::class) - public suspend inline fun <reified T> parseJson(json: LifecycleAwareInputStream): T? = withContext(LIMITED_FS) { - return@withContext moshi.adapter<T>().nullSafe().fromJson(json.source().buffer()) - } - - /** - * Serialize any object to JSON. Uses default set of custom adapters for serializing [AnimeRaw] and [Anime]. - * @since 8.0.0 - * @param obj Any object that is supposed to be serialized to JSON. - * @param options Options that can change the default behavior of the JSON serialization. - * @return Given object serialized in JSON as [String]. - */ - public suspend fun toJson(obj: Any, vararg options: SerializationOptions): String = withContext(LIMITED_CPU) { - return@withContext configureJsonAdapter(SerializationSettings(options.toSet())).toJson(obj) - } - - @OptIn(ExperimentalStdlibApi::class) - private fun configureJsonAdapter(settings: SerializationSettings): MoshiAdapter<Any> { - var jsonAdapter = moshi.adapter<Any>() - - if (settings.serializeNullActivated) { - jsonAdapter = jsonAdapter.serializeNulls() - } - - if (settings.prettyPrintActivated) { - jsonAdapter = jsonAdapter.indent(JSON_IDENT) - } - - return jsonAdapter - } - - /** - * Possible options to customize serialization of JSON documents using [Json.toJson]. - * @since 11.0.0 - */ - public enum class SerializationOptions { - /** - * By default, the output JSON string is formatted. Using this option will create a minified string instead. - * @since 11.0.0 - */ - DEACTIVATE_PRETTY_PRINT, - /** - * By default, a property providing `null` as value will be serialized. Using this option will omit these properties. - * @since 11.0.0 - */ - DEACTIVATE_SERIALIZE_NULL; - } -} - -private class SerializationSettings(val options: Set<Json.SerializationOptions>) { - /** Pretty-print is activated by default. */ - val prettyPrintActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_PRETTY_PRINT)} ?: true - /** Serialize null is activated by default. */ - val serializeNullActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_SERIALIZE_NULL)} ?: true -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.squareup.moshi.addAdapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_FS +import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL +import kotlinx.coroutines.withContext +import okio.buffer +import okio.source +import com.squareup.moshi.JsonAdapter as MoshiAdapter + +/** + * Handles serialization and deserialization of objects to/from JSON. + * @since 11.0.0 + */ +public object Json { + + private const val JSON_IDENT: String = " " + + @PublishedApi + @OptIn(ExperimentalStdlibApi::class) + internal val moshi: Moshi = Moshi.Builder() + .addAdapter(UriAdapter()) + .addAdapter(RelatedAnimeAdapter()) + .addAdapter(DurationAdapter()) + .addAdapter(AnimeTypeAdapter()) + .addAdapter(AnimeStatusAdapter()) + .addAdapter(AnimeSeasonAdapter()) + .addAdapter(AnimeRawAdapter()) + .addAdapter(AnimeAdapter()) + .addAdapter(ScoreAdapter()) + .addAdapter(MetaDataProviderScoreValueAdapter()) + .addLast(KotlinJsonAdapterFactory()) + .build() + + /** + * Parse a [String] into an object. + * + * **WARNING** [Collection]s of a non-nullable type can still contain null. + * @since 8.0.0 + * @param json Valid JSON as [String]. + * @return Deserialized JSON as object of given type [T]. + */ + @OptIn(ExperimentalStdlibApi::class) + public suspend inline fun <reified T> parseJson(json: String): T? = withContext(LIMITED_CPU) { + return@withContext moshi.adapter<T>().nullSafe().fromJson(json) + } + + /** + * Parse an [LifecycleAwareInputStream] into an object. + * + * **WARNING** [Collection]s of a non-nullable type can still contain null. + * @since 8.0.0 + * @param json Valid JSON as [LifecycleAwareInputStream]. + * @return Deserialized JSON as object of given type [T]. + */ + @OptIn(ExperimentalStdlibApi::class) + public suspend inline fun <reified T> parseJson(json: LifecycleAwareInputStream): T? = withContext(LIMITED_FS) { + return@withContext moshi.adapter<T>().nullSafe().fromJson(json.source().buffer()) + } + + /** + * Serialize any object to JSON. Uses default set of custom adapters for serializing [AnimeRaw] and [Anime]. + * @since 8.0.0 + * @param obj Any object that is supposed to be serialized to JSON. + * @param options Options that can change the default behavior of the JSON serialization. + * @return Given object serialized in JSON as [String]. + */ + public suspend fun toJson(obj: Any, vararg options: SerializationOptions): String = withContext(LIMITED_CPU) { + return@withContext configureJsonAdapter(SerializationSettings(options.toSet())).toJson(obj) + } + + @OptIn(ExperimentalStdlibApi::class) + private fun configureJsonAdapter(settings: SerializationSettings): MoshiAdapter<Any> { + var jsonAdapter = moshi.adapter<Any>() + + if (settings.serializeNullActivated) { + jsonAdapter = jsonAdapter.serializeNulls() + } + + if (settings.prettyPrintActivated) { + jsonAdapter = jsonAdapter.indent(JSON_IDENT) + } + + return jsonAdapter + } + + /** + * Possible options to customize serialization of JSON documents using [Json.toJson]. + * @since 11.0.0 + */ + public enum class SerializationOptions { + /** + * By default, the output JSON string is formatted. Using this option will create a minified string instead. + * @since 11.0.0 + */ + DEACTIVATE_PRETTY_PRINT, + /** + * By default, a property providing `null` as value will be serialized. Using this option will omit these properties. + * @since 11.0.0 + */ + DEACTIVATE_SERIALIZE_NULL; + } +} + +private class SerializationSettings(val options: Set<Json.SerializationOptions>) { + /** Pretty-print is activated by default. */ + val prettyPrintActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_PRETTY_PRINT)} ?: true + /** Serialize null is activated by default. */ + val serializeNullActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_SERIALIZE_NULL)} ?: true +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapter.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapter.kt new file mode 100644 index 00000000..542f05e2 --- /dev/null +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapter.kt @@ -0,0 +1,56 @@ +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.* +import com.squareup.moshi.JsonReader.Token.NULL +import com.squareup.moshi.JsonReader.Token.STRING +import io.github.manamiproject.modb.core.anime.RelatedAnime +import java.net.URI + +/** + * Reads and writes [RelatedAnime] as `{"uri": "...", "relation": "..."}`. `relation` is omitted if it is unknown. + * For compatibility with files written before relation types existed, a plain string is read as a link without + * [RelatedAnime.relation]. + */ +internal class RelatedAnimeAdapter: JsonAdapter<RelatedAnime>() { + + @FromJson + override fun fromJson(reader: JsonReader): RelatedAnime { + if (reader.peek() == STRING) { + return RelatedAnime(URI(reader.nextString())) + } + + reader.beginObject() + + var uri: URI? = null + var relation: String? = null + + while (reader.hasNext()) { + when (reader.nextName()) { + "uri" -> uri = URI(reader.nextString()) + "relation" -> relation = if (reader.peek() != NULL) { reader.nextString() } else { reader.nextNull<Unit>(); null } + else -> reader.skipValue() + } + } + + reader.endObject() + + if (uri == null) { + throw IllegalStateException("Property 'uri' is either missing or null.") + } + + return RelatedAnime.of(uri, relation) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: RelatedAnime?) { + requireNotNull(value) { "RelatedAnimeAdapter expects non-nullable value, but received null." } + + writer.beginObject() + writer.name("uri").value(value.uri.toString()) + + // "relation" is left out if the metadata provider doesn't name the relation, regardless of serializing nulls + value.relation?.let { writer.name("relation").value(it) } + + writer.endObject() + } +} diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt index a1e25df8..9c1ee9b5 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt @@ -1,1224 +1,1280 @@ -package io.github.manamiproject.modb.core - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.* -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.OutputKey -import io.github.manamiproject.modb.core.extractor.Selector -import io.github.manamiproject.modb.core.logging.Logger -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import org.slf4j.Marker -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.net.URI -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -import kotlin.reflect.* -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import org.slf4j.Logger as Slf4jLogger - -internal object TestMetaDataProviderConfig : MetaDataProviderConfig { - override fun isTestContext(): Boolean = true - override fun hostname(): Hostname = shouldNotBeInvoked() - override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() - override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() - override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() -} - -internal object TestAnimeConverter : AnimeConverter { - override suspend fun convert(rawContent: String) = shouldNotBeInvoked() -} - -internal object TestDataExtractor : DataExtractor { - override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() -} - -internal object TestConfigRegistry: ConfigRegistry { - override fun string(key: String): String = shouldNotBeInvoked() - override fun long(key: String): Long = shouldNotBeInvoked() - override fun int(key: String): Int = shouldNotBeInvoked() - override fun <T: Any> list(key: String): List<T> = shouldNotBeInvoked() - override fun boolean(key: String): Boolean = shouldNotBeInvoked() - override fun double(key: String): Double = shouldNotBeInvoked() - override fun localDate(key: String): LocalDate = shouldNotBeInvoked() - override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() - override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() - override fun <T: Any> map(key: String): Map<String, T> = shouldNotBeInvoked() -} - -internal class TestKProperty<T>: KProperty<T> { - override val annotations: List<Annotation> - get() = shouldNotBeInvoked() - override val getter: KProperty.Getter<T> - get() = shouldNotBeInvoked() - override val isAbstract: Boolean - get() = shouldNotBeInvoked() - override val isConst: Boolean - get() = shouldNotBeInvoked() - override val isFinal: Boolean - get() = shouldNotBeInvoked() - override val isLateinit: Boolean - get() = shouldNotBeInvoked() - override val isOpen: Boolean - get() = shouldNotBeInvoked() - override val isSuspend: Boolean - get() = shouldNotBeInvoked() - override val name: String - get() = shouldNotBeInvoked() - override val parameters: List<KParameter> - get() = shouldNotBeInvoked() - override val returnType: KType - get() = shouldNotBeInvoked() - override val typeParameters: List<KTypeParameter> - get() = shouldNotBeInvoked() - override val visibility: KVisibility - get() = shouldNotBeInvoked() - override fun call(vararg args: Any?): T = shouldNotBeInvoked() - override fun callBy(args: Map<KParameter, Any?>): T = shouldNotBeInvoked() -} - -internal object TestScoreCalculator: ScoreCalculator { - override fun calculateScore(scores: Collection<MetaDataProviderScore>): Score = shouldNotBeInvoked() -} - -internal object TestLoggerImplementation: Logger { - override fun error(message: () -> String) = shouldNotBeInvoked() - override fun error(exception: Throwable, message: () -> String) = shouldNotBeInvoked() - override fun warn(message: () -> String) = shouldNotBeInvoked() - override fun warn(exception: Throwable, message: () -> String) = shouldNotBeInvoked() - override fun info(message: () -> String) = shouldNotBeInvoked() - override fun debug(message: () -> String) = shouldNotBeInvoked() - override fun trace(message: () -> String) = shouldNotBeInvoked() -} - -internal object TestSlf4jImplementation: Slf4jLogger { - override fun getName(): String = shouldNotBeInvoked() - override fun isTraceEnabled(): Boolean = shouldNotBeInvoked() - override fun isTraceEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun trace(msg: String?) = shouldNotBeInvoked() - override fun trace(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun trace(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun trace(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun trace(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, format: String?, vararg argArray: Any?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isDebugEnabled(): Boolean = shouldNotBeInvoked() - override fun isDebugEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun debug(msg: String?) = shouldNotBeInvoked() - override fun debug(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun debug(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun debug(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun debug(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isInfoEnabled(): Boolean = shouldNotBeInvoked() - override fun isInfoEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun info(msg: String?) = shouldNotBeInvoked() - override fun info(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun info(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun info(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun info(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun info(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun info(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun info(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun info(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun info(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isWarnEnabled(): Boolean = shouldNotBeInvoked() - override fun isWarnEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun warn(msg: String?) = shouldNotBeInvoked() - override fun warn(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun warn(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun warn(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun warn(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isErrorEnabled(): Boolean = shouldNotBeInvoked() - override fun isErrorEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun error(msg: String?) = shouldNotBeInvoked() - override fun error(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun error(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun error(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun error(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun error(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun error(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun error(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun error(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun error(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() -} - -@Suppress("unused") -internal object TestAnimeRawObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: AnimeRaw - get() = AnimeRaw("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - _synonyms = hashSetOf( - "DN", - "デスノート", - ), - _studios = hashSetOf( - "madhouse", - ), - _producers = hashSetOf( - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - _tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":["https://myanimelist.net/anime/2994"],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Go-toubun no Hanayome *", - _sources = hashSetOf( - URI("https://anilist.co/anime/177191"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/131520"), - ), - _tags = hashSetOf( - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anilist.co/anime/177191" - ], - "title": "Go-toubun no Hanayome *", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - } - ], - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://anilist.co/anime/131520" - ], - "tags": [ - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://anilist.co/anime/131520"],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "5-toubun no Hanayome*", - _sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - URI("https://anidb.net/anime/16165"), - URI("https://anilist.co/anime/131520"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie"), - URI("https://animecountdown.com/1577789"), - URI("https://anisearch.com/anime/16091"), - URI("https://kitsu.app/anime/44229"), - URI("https://livechart.me/anime/10488"), - URI("https://livechart.me/anime/11921"), - URI("https://livechart.me/anime/3448"), - URI("https://livechart.me/anime/9428"), - URI("https://myanimelist.net/anime/48548"), - URI("https://simkl.com/anime/1577789"), - ), - _tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anidb.net", - value = 6.63, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "anime-planet.com", - value = 3.845, - range = 0.5..5.0, - ), - MetaDataProviderScoreValue( - hostname = "anisearch.com", - value = 3.9, - range = 0.1..5.0, - ), - MetaDataProviderScoreValue( - hostname = "kitsu.app", - value = 76.95, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "livechart.me", - value = 8.35, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "simkl.com", - value = 7.9, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anidb.net", - "value": 6.63, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "anime-planet.com", - "value": 3.845, - "range": { - "minInclusive": 0.5, - "maxInclusive": 5.0 - } - }, - { - "hostname": "anisearch.com", - "value": 3.9, - "range": { - "minInclusive": 0.1, - "maxInclusive": 5.0 - } - }, - { - "hostname": "kitsu.app", - "value": 76.95, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "livechart.me", - "value": 8.35, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "simkl.com", - "value": 7.9, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://anidb.net/anime/16165", - "https://anilist.co/anime/131520", - "https://anime-planet.com/anime/the-quintessential-quintuplets-movie", - "https://animecountdown.com/1577789", - "https://anisearch.com/anime/16091", - "https://kitsu.app/anime/44229", - "https://livechart.me/anime/10488", - "https://livechart.me/anime/11921", - "https://livechart.me/anime/3448", - "https://livechart.me/anime/9428", - "https://myanimelist.net/anime/48548", - "https://simkl.com/anime/1577789" - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://anidb.net/anime/16165","https://anilist.co/anime/131520","https://anime-planet.com/anime/the-quintessential-quintuplets-movie","https://animecountdown.com/1577789","https://anisearch.com/anime/16091","https://kitsu.app/anime/44229","https://livechart.me/anime/10488","https://livechart.me/anime/11921","https://livechart.me/anime/3448","https://livechart.me/anime/9428","https://myanimelist.net/anime/48548","https://simkl.com/anime/1577789"],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -@Suppress("unused") -internal object TestAnimeObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: Anime - get() = Anime("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "score": null, - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: Anime - get() = Anime( - title = "Death Note", - sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - score = NoScore, - synonyms = hashSetOf( - "DN", - "デスノート", - ), - studios = hashSetOf( - "madhouse", - ), - producers = hashSetOf( - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap", - ), - relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "score": null, - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":["https://myanimelist.net/anime/2994"],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://myanimelist.net/anime/58755"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.44, - arithmeticMean = 7.44, - median = 7.44, - ), - synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/48548"), - ), - tags = hashSetOf( - "comedy", - "harem", - "romance", - "school", - "shounen", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/58755" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.44, - "arithmeticMean": 7.44, - "median": 7.44 - }, - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/48548" - ], - "tags": [ - "comedy", - "harem", - "romance", - "school", - "shounen" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://myanimelist.net/anime/48548"],"tags":["comedy","harem","romance","school","shounen"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.616175305477198, - arithmeticMean = 7.624601113172541, - median = 7.7272727272727275, - ), - synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - "tbs", - ), - relatedAnime = hashSetOf( - URI("https://anidb.net/anime/16165"), - URI("https://anilist.co/anime/131520"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie"), - URI("https://animecountdown.com/1577789"), - URI("https://anisearch.com/anime/16091"), - URI("https://kitsu.app/anime/44229"), - URI("https://livechart.me/anime/10488"), - URI("https://livechart.me/anime/11921"), - URI("https://livechart.me/anime/3448"), - URI("https://livechart.me/anime/9428"), - URI("https://myanimelist.net/anime/48548"), - URI("https://simkl.com/anime/1577789"), - ), - tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ) - - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.616175305477198, - "arithmeticMean": 7.624601113172541, - "median": 7.7272727272727275 - }, - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon", - "tbs" - ], - "relatedAnime": [ - "https://anidb.net/anime/16165", - "https://anilist.co/anime/131520", - "https://anime-planet.com/anime/the-quintessential-quintuplets-movie", - "https://animecountdown.com/1577789", - "https://anisearch.com/anime/16091", - "https://kitsu.app/anime/44229", - "https://livechart.me/anime/10488", - "https://livechart.me/anime/11921", - "https://livechart.me/anime/3448", - "https://livechart.me/anime/9428", - "https://myanimelist.net/anime/48548", - "https://simkl.com/anime/1577789" - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":["https://anidb.net/anime/16165","https://anilist.co/anime/131520","https://anime-planet.com/anime/the-quintessential-quintuplets-movie","https://animecountdown.com/1577789","https://anisearch.com/anime/16091","https://kitsu.app/anime/44229","https://livechart.me/anime/10488","https://livechart.me/anime/11921","https://livechart.me/anime/3448","https://livechart.me/anime/9428","https://myanimelist.net/anime/48548","https://simkl.com/anime/1577789"],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { - private var closed = false - - override fun read(): Int { - if (closed) throw IOException("Stream closed") - return delegate.read() - } - - override fun close() { - closed = true - delegate.close() - } -} - -internal open class TestInputStream: InputStream() { - override fun close(): Unit = shouldNotBeInvoked() - override fun read(): Int = shouldNotBeInvoked() - override fun read(b: ByteArray?): Int = shouldNotBeInvoked() - override fun read(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() - override fun readAllBytes(): ByteArray? = shouldNotBeInvoked() - override fun readNBytes(len: Int): ByteArray? = shouldNotBeInvoked() - override fun readNBytes(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() - override fun skip(n: Long): Long = shouldNotBeInvoked() - override fun skipNBytes(n: Long): Unit = shouldNotBeInvoked() - override fun available(): Int = shouldNotBeInvoked() - override fun mark(readlimit: Int): Unit = shouldNotBeInvoked() - override fun markSupported(): Boolean = shouldNotBeInvoked() - override fun reset(): Unit = shouldNotBeInvoked() - override fun transferTo(out: OutputStream?): Long = shouldNotBeInvoked() - override fun equals(other: Any?): Boolean = shouldNotBeInvoked() - override fun hashCode(): Int = shouldNotBeInvoked() - override fun toString(): String = shouldNotBeInvoked() -} \ No newline at end of file +package io.github.manamiproject.modb.core + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.* +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.OutputKey +import io.github.manamiproject.modb.core.extractor.Selector +import io.github.manamiproject.modb.core.logging.Logger +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import org.slf4j.Marker +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.URI +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import kotlin.reflect.* +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import org.slf4j.Logger as Slf4jLogger + +internal object TestMetaDataProviderConfig : MetaDataProviderConfig { + override fun isTestContext(): Boolean = true + override fun hostname(): Hostname = shouldNotBeInvoked() + override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() + override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() + override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() +} + +internal object TestAnimeConverter : AnimeConverter { + override suspend fun convert(rawContent: String) = shouldNotBeInvoked() +} + +internal object TestDataExtractor : DataExtractor { + override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() +} + +internal object TestConfigRegistry: ConfigRegistry { + override fun string(key: String): String = shouldNotBeInvoked() + override fun long(key: String): Long = shouldNotBeInvoked() + override fun int(key: String): Int = shouldNotBeInvoked() + override fun <T: Any> list(key: String): List<T> = shouldNotBeInvoked() + override fun boolean(key: String): Boolean = shouldNotBeInvoked() + override fun double(key: String): Double = shouldNotBeInvoked() + override fun localDate(key: String): LocalDate = shouldNotBeInvoked() + override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() + override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() + override fun <T: Any> map(key: String): Map<String, T> = shouldNotBeInvoked() +} + +internal class TestKProperty<T>: KProperty<T> { + override val annotations: List<Annotation> + get() = shouldNotBeInvoked() + override val getter: KProperty.Getter<T> + get() = shouldNotBeInvoked() + override val isAbstract: Boolean + get() = shouldNotBeInvoked() + override val isConst: Boolean + get() = shouldNotBeInvoked() + override val isFinal: Boolean + get() = shouldNotBeInvoked() + override val isLateinit: Boolean + get() = shouldNotBeInvoked() + override val isOpen: Boolean + get() = shouldNotBeInvoked() + override val isSuspend: Boolean + get() = shouldNotBeInvoked() + override val name: String + get() = shouldNotBeInvoked() + override val parameters: List<KParameter> + get() = shouldNotBeInvoked() + override val returnType: KType + get() = shouldNotBeInvoked() + override val typeParameters: List<KTypeParameter> + get() = shouldNotBeInvoked() + override val visibility: KVisibility + get() = shouldNotBeInvoked() + override fun call(vararg args: Any?): T = shouldNotBeInvoked() + override fun callBy(args: Map<KParameter, Any?>): T = shouldNotBeInvoked() +} + +internal object TestScoreCalculator: ScoreCalculator { + override fun calculateScore(scores: Collection<MetaDataProviderScore>): Score = shouldNotBeInvoked() +} + +internal object TestLoggerImplementation: Logger { + override fun error(message: () -> String) = shouldNotBeInvoked() + override fun error(exception: Throwable, message: () -> String) = shouldNotBeInvoked() + override fun warn(message: () -> String) = shouldNotBeInvoked() + override fun warn(exception: Throwable, message: () -> String) = shouldNotBeInvoked() + override fun info(message: () -> String) = shouldNotBeInvoked() + override fun debug(message: () -> String) = shouldNotBeInvoked() + override fun trace(message: () -> String) = shouldNotBeInvoked() +} + +internal object TestSlf4jImplementation: Slf4jLogger { + override fun getName(): String = shouldNotBeInvoked() + override fun isTraceEnabled(): Boolean = shouldNotBeInvoked() + override fun isTraceEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun trace(msg: String?) = shouldNotBeInvoked() + override fun trace(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun trace(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun trace(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun trace(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, format: String?, vararg argArray: Any?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isDebugEnabled(): Boolean = shouldNotBeInvoked() + override fun isDebugEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun debug(msg: String?) = shouldNotBeInvoked() + override fun debug(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun debug(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun debug(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun debug(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isInfoEnabled(): Boolean = shouldNotBeInvoked() + override fun isInfoEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun info(msg: String?) = shouldNotBeInvoked() + override fun info(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun info(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun info(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun info(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun info(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun info(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun info(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun info(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun info(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isWarnEnabled(): Boolean = shouldNotBeInvoked() + override fun isWarnEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun warn(msg: String?) = shouldNotBeInvoked() + override fun warn(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun warn(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun warn(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun warn(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isErrorEnabled(): Boolean = shouldNotBeInvoked() + override fun isErrorEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun error(msg: String?) = shouldNotBeInvoked() + override fun error(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun error(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun error(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun error(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun error(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun error(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun error(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun error(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun error(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() +} + +@Suppress("unused") +internal object TestAnimeRawObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: AnimeRaw + get() = AnimeRaw("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + _synonyms = hashSetOf( + "DN", + "デスノート", + ), + _studios = hashSetOf( + "madhouse", + ), + _producers = hashSetOf( + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + _tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Go-toubun no Hanayome *", + _sources = hashSetOf( + URI("https://anilist.co/anime/177191"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/131520")), + ), + _tags = hashSetOf( + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anilist.co/anime/177191" + ], + "title": "Go-toubun no Hanayome *", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + } + ], + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anilist.co/anime/131520" + } + ], + "tags": [ + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "5-toubun no Hanayome*", + _sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + _tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anidb.net", + value = 6.63, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "anime-planet.com", + value = 3.845, + range = 0.5..5.0, + ), + MetaDataProviderScoreValue( + hostname = "anisearch.com", + value = 3.9, + range = 0.1..5.0, + ), + MetaDataProviderScoreValue( + hostname = "kitsu.app", + value = 76.95, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "livechart.me", + value = 8.35, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "simkl.com", + value = 7.9, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anidb.net", + "value": 6.63, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "anime-planet.com", + "value": 3.845, + "range": { + "minInclusive": 0.5, + "maxInclusive": 5.0 + } + }, + { + "hostname": "anisearch.com", + "value": 3.9, + "range": { + "minInclusive": 0.1, + "maxInclusive": 5.0 + } + }, + { + "hostname": "kitsu.app", + "value": 76.95, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "livechart.me", + "value": 8.35, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "simkl.com", + "value": 7.9, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +@Suppress("unused") +internal object TestAnimeObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: Anime + get() = Anime("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "score": null, + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: Anime + get() = Anime( + title = "Death Note", + sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + score = NoScore, + synonyms = hashSetOf( + "DN", + "デスノート", + ), + studios = hashSetOf( + "madhouse", + ), + producers = hashSetOf( + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "score": null, + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://myanimelist.net/anime/58755"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.44, + arithmeticMean = 7.44, + median = 7.44, + ), + synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + ), + tags = hashSetOf( + "comedy", + "harem", + "romance", + "school", + "shounen", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/58755" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.44, + "arithmeticMean": 7.44, + "median": 7.44 + }, + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/48548" + } + ], + "tags": [ + "comedy", + "harem", + "romance", + "school", + "shounen" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.616175305477198, + arithmeticMean = 7.624601113172541, + median = 7.7272727272727275, + ), + synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + "tbs", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ) + + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.616175305477198, + "arithmeticMean": 7.624601113172541, + "median": 7.7272727272727275 + }, + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon", + "tbs" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { + private var closed = false + + override fun read(): Int { + if (closed) throw IOException("Stream closed") + return delegate.read() + } + + override fun close() { + closed = true + delegate.close() + } +} + +internal open class TestInputStream: InputStream() { + override fun close(): Unit = shouldNotBeInvoked() + override fun read(): Int = shouldNotBeInvoked() + override fun read(b: ByteArray?): Int = shouldNotBeInvoked() + override fun read(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() + override fun readAllBytes(): ByteArray? = shouldNotBeInvoked() + override fun readNBytes(len: Int): ByteArray? = shouldNotBeInvoked() + override fun readNBytes(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() + override fun skip(n: Long): Long = shouldNotBeInvoked() + override fun skipNBytes(n: Long): Unit = shouldNotBeInvoked() + override fun available(): Int = shouldNotBeInvoked() + override fun mark(readlimit: Int): Unit = shouldNotBeInvoked() + override fun markSupported(): Boolean = shouldNotBeInvoked() + override fun reset(): Unit = shouldNotBeInvoked() + override fun transferTo(out: OutputStream?): Long = shouldNotBeInvoked() + override fun equals(other: Any?): Boolean = shouldNotBeInvoked() + override fun hashCode(): Int = shouldNotBeInvoked() + override fun toString(): String = shouldNotBeInvoked() +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt index 7580e1d8..bb59ec1a 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt @@ -1,9328 +1,9447 @@ -package io.github.manamiproject.modb.core.anime - -import io.github.manamiproject.modb.core.TestAnimeRawObjects -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeStatus.ONGOING -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.json.AnimeRawAdapter -import io.github.manamiproject.modb.test.exceptionExpected -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeRawTest { - - @Nested - inner class TitleTests { - - @Test - fun `remove leading whitespace from title`() { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw(" $expectedTitle") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @Test - fun `remove tailing whitespace from title`() { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw("$expectedTitle ") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in title`() { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw("Death${input}Note") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // when - val result = AnimeRaw("Ba${input}ek") - - // then - assertThat(result.title).isEqualTo("Baek") - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `throws exception if title is blank`(value: String) { - // when - val result = assertThrows<IllegalArgumentException> { - AnimeRaw(value) - } - - // then - assertThat(result).hasMessage("Title cannot be blank.") - } - - @Test - fun `don't change title if activateChecks is set to false`() { - // given - val expectedTitle = " Death Note " - - // when - val result = AnimeRaw( - _title = expectedTitle, - activateChecks = false, - ) - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `throws an exception if title is blank even if activateChecks is set to false`(value: String) { - // when - val result = exceptionExpected<IllegalArgumentException> { - AnimeRaw( - _title = value, - activateChecks = false, - ) - } - - // then - assertThat(result).hasMessage("Title cannot be blank.") - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _synonyms = hashSetOf( - "other title1", - "other title2", - ), - ) - - // when - anime.synonyms.clear() - - // then - assertThat(anime.synonyms).isNotEmpty() - } - - @Nested - inner class AddSynonymsConstructorTests { - - @Test - fun `must not add a synonym if it equals the title`() { - // given - val title = "Death Note" - - // when - val result = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - title, - ), - ) - - // then - assertThat(result.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank synonym`(input: String) { - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Caderno${input}da${input}Morte", - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Cader${input}no da Mor${input}te", - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @Test - fun `remove leading whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - - // when - val result = AnimeRaw( - _title = "Title", - _synonyms = hashSetOf( - " $expectedTitleOne", - " $expectedTitleTwo", - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove tailing whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - - // when - val result = AnimeRaw( - _title = "Title", - _synonyms = hashSetOf( - "$expectedTitleOne ", - "$expectedTitleTwo ", - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - - // when - val result = AnimeRaw( - _title = "Title", - _synonyms = hashSetOf( - "Death Note", - "Made in Abyss", - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `successfully add a synonym`() { - // given - val synonym = "Caderno da Morte" - - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - synonym, - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - synonym, - ) - } - - @Test - fun `must not add a duplicated synonym`() { - // given - val synonym = "Caderno da Morte" - - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - synonym, - synonym, - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - synonym, - ) - } - - @Test - fun `synonym comparison to title is not case-sensitive`() { - // given - val title = "Death Note" - - // when - val result = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - title.uppercase(), - title.lowercase(), - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - title.uppercase(), - title.lowercase(), - ) - } - - @Test - fun `synonym comparison is not case-sensitive`() { - // given - val title = "Death Note" - - // when - val result = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - title, - title.lowercase(), - title.uppercase(), - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - title, - title.uppercase(), - title.lowercase(), - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `correctly normalized usages of apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _synonyms = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - "she\u2019s, John\u2019s, it\u2019s", - "the girls\u2019, the dogs\u2019", - "don\u2019t, can\u2019t", - "I\u2019m", - "I\u2019d, they\u2019d", - "rock \u2019n\u2019 roll", - "he\u2019ll, we\u2019ll", - "your\u2019re", - "I\u2019ve", - "\u2019ere", - "\u2019cause", - "\u2019bout", - "\u2019fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _synonyms = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "", " ", " ", "\u200C"]) - fun `doesn't fix synonyms if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf(value), - activateChecks = false, - ) - - // then - assertThat(obj.synonyms).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddSynonymsTests { - - @Test - fun `must not add a synonym if it equals the title`() { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms(listOf( - "Death Note", - )) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank synonym`(input: String) { - // given - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addSynonyms(listOf( - "$input $input", - )) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Caderno da Morte" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "Death${input}Note", - "Caderno${input}da${input}Morte", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Caderno da Morte" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "Death Note", - "Cader${input}no da Mor${input}te", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove leading whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - " $expectedTitleOne", - " $expectedTitleTwo", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove tailing whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "$expectedTitleOne ", - "$expectedTitleTwo ", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "Death Note", - "Made in Abyss", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `successfully add a list of synonyms`() { - // given - val one = "Caderno da Morte" - val two = "DN" - val three = "Quaderno della Morte" - val four = "Sveska Smrti" - - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms(listOf( - four, - two, - three, - one, - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - three, - four, - ) - } - - @Test - fun `must not add a duplicated synonym`() { - // given - val one = "Caderno da Morte" - val two = "DN" - - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - two, - ), - ) - - // when - anime.addSynonyms(listOf( - two, - one, - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - ) - } - - @Test - fun `synonym comparison to title is not case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw(title) - - // when - anime.addSynonyms(listOf( - title.uppercase(), - title.lowercase(), - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title.uppercase(), - title.lowercase(), - ) - } - - @Test - fun `synonym comparison is not case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - title.lowercase(), - title.uppercase(), - ), - ) - - // when - anime.addSynonyms(title) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title, - title.uppercase(), - title.lowercase(), - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she\u2019s, John\u2019s, it\u2019s", - "the girls\u2019, the dogs\u2019", - "don\u2019t, can\u2019t", - "I\u2019m", - "I\u2019d, they\u2019d", - "rock \u2019n\u2019 roll", - "he\u2019ll, we\u2019ll", - "your\u2019re", - "I\u2019ve", - "\u2019ere", - "\u2019cause", - "\u2019bout", - "\u2019fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddSynonymsVarargTests { - - @Test - fun `must not add a synonym if it equals the title`() { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "Death Note", - ) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank synonym`(input: String) { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "$input $input", - ) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "Caderno${input}da${input}Morte", - ) - - // then - assertThat(anime.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "Cader${input}no da Mor${input}te", - ) - - // then - assertThat(anime.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @Test - fun `remove leading whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms( - " $expectedTitleOne", - " $expectedTitleTwo", - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove tailing whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms( - "$expectedTitleOne ", - "$expectedTitleTwo ", - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms( - "Death Note", - "Made in Abyss", - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `successfully add synonyms`() { - // given - val one = "Caderno da Morte" - val two = "DN" - val three = "Quaderno della Morte" - val four = "Sveska Smrti" - - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - four, - two, - three, - one, - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - three, - four, - ) - } - - @Test - fun `must not add a duplicated synonym`() { - // given - val one = "Caderno da Morte" - val two = "DN" - - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - two, - ), - ) - - // when - anime.addSynonyms( - two, - one, - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - ) - } - - @Test - fun `synonym comparison to title is case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw(title) - - // when - anime.addSynonyms( - title.uppercase(), - title.lowercase(), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title.uppercase(), - title.lowercase(), - ) - } - - @Test - fun `synonym comparison is not case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw("デスノート") - - // when - anime.addSynonyms( - title, - title.lowercase(), - title.uppercase(), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title, - title.uppercase(), - title.lowercase(), - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she\u2019s, John\u2019s, it\u2019s", - "the girls\u2019, the dogs\u2019", - "don\u2019t, can\u2019t", - "I\u2019m", - "I\u2019d, they\u2019d", - "rock \u2019n\u2019 roll", - "he\u2019ll, we\u2019ll", - "your\u2019re", - "I\u2019ve", - "\u2019ere", - "\u2019cause", - "\u2019bout", - "\u2019fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class RemoveSynonymIfTests { - - @Test - fun `successfully remove synonym`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Notatnik smierci", - ), - ) - - // when - anime.removeSynonymIf { it == "Notatnik smierci" } - - // then - assertThat(anime.synonyms).isEmpty() - } - - @Test - fun `don't remove anything if condition doesn't match`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Notatnik smierci", - ), - ) - - // when - anime.removeSynonymIf { it.contains("Bleach") } - - // then - assertThat(anime.synonyms).containsExactly( - "Notatnik smierci", - ) - } - } - } - - @Nested - inner class StudiosTest { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - // when - anime.studios.clear() - - // then - assertThat(anime.studios).isNotEmpty() - } - - @Nested - inner class AddStudiosConstructorTests { - - @Test - fun `studios added by constructor are set to lower case`() { - // given - val studio = "EXAMPLE" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio, - ), - ) - - // then - assertThat(result.studios).containsExactly( - studio.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from studios`() { - // given - val expectedStudio = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - " $expectedStudio", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `remove tailing whitespace from studios`() { - // given - val expectedStudio = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "$expectedStudio ", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in studios`() { - // given - val expectedStudio = "bibury animation studios" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "bibury animation studios", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.studios).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedStudio = "bibury animation studios" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "bibury${input}animation${input}studios", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedStudio = "bibury animation studios" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "bi${input}bury ani${input}mation stu${input}dios", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val studio = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf(), - ) - - // then - assertThat(result.studios).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val studio1 = "a studio" - val studio2 = "before the other" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val studio1 = "satelight" - val studio2 = "satelight inc." - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val studio1 = "gallop" - val studio2 = "studio gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - studio3, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "tokyo HQ gallop" - val studio4 = "gallop made up" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - studio3, - studio4, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val studio1 = "anime beans" - val studio2 = "tate anime" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val studio1 = "studio durian" - val studio2 = "wit studio" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val studio1 = "sotsu" - val studio2 = "square enix" - val studio3 = "tv aichi" - val studio4 = "tv osaka" - val studio5 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val studio1 = "tokyo kids" - val studio2 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val studio1 = "groove corporation" - val studio2 = "group tac" - val studio3 = "hitsuji no uta production committee" - val studio4 = "inter communications inc." - val studio5 = "ken groove" - val studio6 = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val studio1 = "kitty media" - val studio2 = "media blasters" - val studio3 = "on-lead" - val studio4 = "venet" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val studio1 = "009 re:cyborg production committee" - val studio2 = "amazonlaterna co,ltd." - val studio3 = "funimation" - val studio4 = "ishimori production" - val studio5 = "nippon television network corporation" - val studio6 = "production i.g" - val studio7 = "sanzigen" - val studio8 = "t-joy" - val studio9 = "vap" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val studio1 = "animation do" - val studio2 = "chu-2byo production partners" - val studio3 = "kyoto animation" - val studio4 = "lantis" - val studio5 = "pony canyon" - val studio6 = "rakuonsha" - val studio7 = "tbs" - val studio8 = "tokyo broadcasting system" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val studio1 = "chiba television" - val studio2 = "geneon universal entertainment" - val studio3 = "kbs kyoto" - val studio4 = "medil" - val studio5 = "mie television broadcasting" - val studio6 = "sun tv" - val studio7 = "television kanagawa" - val studio8 = "television saitama co., ltd." - val studio9 = "thefool" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val studio1 = "group tac" - val studio2 = "nippon herald film group" - val studio3 = "the asahi shimbun company" - val studio4 = "tv asahi" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _studios = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _studios = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) - fun `doesn't fix studios if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _studios = hashSetOf( - value, - ), - activateChecks = false, - ) - - // then - assertThat(obj.studios).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddStudiosTests { - - @Test - fun `studios added are set to lower case`() { - // given - val studio = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio, - )) - - // then - assertThat(anime.studios).containsExactly( - studio.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from studios`() { - // given - val expectedStudio = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - " $expectedStudio", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `remove tailing whitespace from studios`() { - // given - val expectedStudios = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "$expectedStudios ", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudios, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in studios`() { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "bibury animation studios", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "$input $input", - )) - - // then - assertThat(anime.studios).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "bibury${input}animation${input}studios", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "bi${input}bury ani${input}mation stu${input}dios", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val studio = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf()) - - // then - assertThat(anime.studios).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val studio1 = "a studio" - val studio2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val studio1 = "satelight" - val studio2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val studio1 = "gallop" - val studio2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - studio3, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "tokyo HQ gallop" - val studio4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val studio1 = "anime beans" - val studio2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val studio1 = "studio durian" - val studio2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val studio1 = "sotsu" - val studio2 = "square enix" - val studio3 = "tv aichi" - val studio4 = "tv osaka" - val studio5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val studio1 = "tokyo kids" - val studio2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val studio1 = "groove corporation" - val studio2 = "group tac" - val studio3 = "hitsuji no uta production committee" - val studio4 = "inter communications inc." - val studio5 = "kenmedia" - val studio6 = "ken groove" - val studio7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val studio1 = "kitty media" - val studio2 = "media blasters" - val studio3 = "on-lead" - val studio4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val studio1 = "009 re:cyborg production committee" - val studio2 = "amazonlaterna co,ltd." - val studio3 = "funimation" - val studio4 = "ishimori production" - val studio5 = "nippon television network corporation" - val studio6 = "production i.g" - val studio7 = "sanzigen" - val studio8 = "t-joy" - val studio9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val studio1 = "animation do" - val studio2 = "chu-2byo production partners" - val studio3 = "kyoto animation" - val studio4 = "lantis" - val studio5 = "pony canyon" - val studio6 = "rakuonsha" - val studio7 = "tbs" - val studio8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val studio1 = "chiba television" - val studio2 = "geneon universal entertainment" - val studio3 = "kbs kyoto" - val studio4 = "medil" - val studio5 = "mie television broadcasting" - val studio6 = "sun tv" - val studio7 = "television kanagawa" - val studio8 = "television saitama co., ltd." - val studio9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val studio1 = "group tac" - val studio2 = "nippon herald film group" - val studio3 = "the asahi shimbun company" - val studio4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios(listOf( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddStudiosVarargTests { - - @Test - fun `studios added are set to lower case`() { - // given - val studio = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(studio) - - // then - assertThat(anime.studios).containsExactly( - studio.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from studios`() { - // given - val expectedStudio = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - " $expectedStudio", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `remove tailing whitespace from studios`() { - // given - val expectedStudio = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "$expectedStudio ", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in studios`() { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "bibury animation studios", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "$input $input", - ) - - // then - assertThat(anime.studios).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "bibury${input}animation${input}studios", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "bi${input}bury ani${input}mation stu${input}dios", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val studio = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios() - - // then - assertThat(anime.studios).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val studio1 = "a studio" - val studio2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio2, - studio1, - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val studio1 = "satelight" - val studio2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val studio1 = "gallop" - val studio2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio3, - studio2, - studio1, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "tokyo HQ gallop" - val studio4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val studio1 = "anime beans" - val studio2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val studio1 = "studio durian" - val studio2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val studio1 = "sotsu" - val studio2 = "square enix" - val studio3 = "tv aichi" - val studio4 = "tv osaka" - val studio5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val studio1 = "tokyo kids" - val studio2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val studio1 = "groove corporation" - val studio2 = "group tac" - val studio3 = "hitsuji no uta production committee" - val studio4 = "inter communications inc." - val studio5 = "kenmedia" - val studio6 = "ken groove" - val studio7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val studio1 = "kitty media" - val studio2 = "media blasters" - val studio3 = "on-lead" - val studio4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val studio1 = "009 re:cyborg production committee" - val studio2 = "amazonlaterna co,ltd." - val studio3 = "funimation" - val studio4 = "ishimori production" - val studio5 = "nippon television network corporation" - val studio6 = "production i.g" - val studio7 = "sanzigen" - val studio8 = "t-joy" - val studio9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val studio1 = "animation do" - val studio2 = "chu-2byo production partners" - val studio3 = "kyoto animation" - val studio4 = "lantis" - val studio5 = "pony canyon" - val studio6 = "rakuonsha" - val studio7 = "tbs" - val studio8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val studio1 = "chiba television" - val studio2 = "geneon universal entertainment" - val studio3 = "kbs kyoto" - val studio4 = "medil" - val studio5 = "mie television broadcasting" - val studio6 = "sun tv" - val studio7 = "television kanagawa" - val studio8 = "television saitama co., ltd." - val studio9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val studio1 = "group tac" - val studio2 = "nippon herald film group" - val studio3 = "the asahi shimbun company" - val studio4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - } - - @Nested - inner class ProducersTest { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - // when - anime.producers.clear() - - // then - assertThat(anime.producers).isNotEmpty() - } - - @Nested - inner class AddProducersConstructorTests { - - @Test - fun `producers added by constructor are set to lower case`() { - // given - val producer = "EXAMPLE" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer, - ), - ) - - // then - assertThat(result.producers).containsExactly( - producer.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from producers`() { - // given - val expectedProducer = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - " $expectedProducer", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `remove tailing whitespace from producers`() { - // given - val expectedProducer = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "$expectedProducer ", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in producers`() { - // given - val expectedProducer = "pony canyon" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "pony canyon", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add producer if it's a blank string`(input: String) { - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.producers).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedProducer = "pony canyon" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "pony${input}canyon", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedProducer = "pony canyon" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "po${input}ny can${input}yon", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val producer = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf(), - ) - - // then - assertThat(result.producers).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val producer1 = "a producer" - val producer2 = "before the other" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer1, - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val producer1 = "satelight" - val producer2 = "satelight inc." - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer1, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val producer1 = "gallop" - val producer2 = "studio gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer1, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer3, - producer1, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "tokyo HQ gallop" - val producer4 = "gallop made up" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val producer1 = "anime beans" - val producer2 = "tate anime" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val producer1 = "studio durian" - val producer2 = "wit studio" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val producer1 = "sotsu" - val producer2 = "square enix" - val producer3 = "tv aichi" - val producer4 = "tv osaka" - val producer5 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val producer1 = "tokyo kids" - val producer2 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val producer1 = "groove corporation" - val producer2 = "group tac" - val producer3 = "hitsuji no uta production committee" - val producer4 = "inter communications inc." - val producer5 = "kenmedia" - val producer6 = "ken groove" - val producer7 = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val producer1 = "kitty media" - val producer2 = "media blasters" - val producer3 = "on-lead" - val producer4 = "venet" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val producer1 = "009 re:cyborg production committee" - val producer2 = "amazonlaterna co,ltd." - val producer3 = "funimation" - val producer4 = "ishimori production" - val producer5 = "nippon television network corporation" - val producer6 = "production i.g" - val producer7 = "sanzigen" - val producer8 = "t-joy" - val producer9 = "vap" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val producer1 = "animation do" - val producer2 = "chu-2byo production partners" - val producer3 = "kyoto animation" - val producer4 = "lantis" - val producer5 = "pony canyon" - val producer6 = "rakuonsha" - val producer7 = "tbs" - val producer8 = "tokyo broadcasting system" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val producer1 = "chiba television" - val producer2 = "geneon universal entertainment" - val producer3 = "kbs kyoto" - val producer4 = "medil" - val producer5 = "mie television broadcasting" - val producer6 = "sun tv" - val producer7 = "television kanagawa" - val producer8 = "television saitama co., ltd." - val producer9 = "thefool" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val producer1 = "group tac" - val producer2 = "nippon herald film group" - val producer3 = "the asahi shimbun company" - val producer4 = "tv asahi" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _producers = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _producers = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) - fun `doesn't fix producers if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _producers = hashSetOf( - value, - ), - activateChecks = false, - ) - - // then - assertThat(obj.producers).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddProducersTests { - - @Test - fun `producers added are set to lower case`() { - // given - val producer = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer, - )) - - // then - assertThat(anime.producers).containsExactly( - producer.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - " $expectedProducer", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `remove tailing whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "$expectedProducer ", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in producers`() { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "pony canyon", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "$input $input", - )) - - // then - assertThat(anime.producers).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "pony${input}canyon", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "po${input}ny can${input}yon", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val producer = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf()) - - // then - assertThat(anime.producers).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val producer1 = "a producer" - val producer2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer2, - producer1, - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val producer1 = "satelight" - val producer2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val producer1 = "gallop" - val producer2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer2, - producer3, - producer1, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "tokyo HQ gallop" - val producer4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val producer1 = "anime beans" - val producer2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val producer1 = "studio durian" - val producer2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val producer1 = "sotsu" - val producer2 = "square enix" - val producer3 = "tv aichi" - val producer4 = "tv osaka" - val producer5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val producer1 = "tokyo kids" - val producer2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val producer1 = "groove corporation" - val producer2 = "group tac" - val producer3 = "hitsuji no uta production committee" - val producer4 = "inter communications inc." - val producer5 = "kenmedia" - val producer6 = "ken groove" - val producer7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val producer1 = "kitty media" - val producer2 = "media blasters" - val producer3 = "on-lead" - val producer4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val producer1 = "009 re:cyborg production committee" - val producer2 = "amazonlaterna co,ltd." - val producer3 = "funimation" - val producer4 = "ishimori production" - val producer5 = "nippon television network corporation" - val producer6 = "production i.g" - val producer7 = "sanzigen" - val producer8 = "t-joy" - val producer9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val producer1 = "animation do" - val producer2 = "chu-2byo production partners" - val producer3 = "kyoto animation" - val producer4 = "lantis" - val producer5 = "pony canyon" - val producer6 = "rakuonsha" - val producer7 = "tbs" - val producer8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val producer1 = "chiba television" - val producer2 = "geneon universal entertainment" - val producer3 = "kbs kyoto" - val producer4 = "medil" - val producer5 = "mie television broadcasting" - val producer6 = "sun tv" - val producer7 = "television kanagawa" - val producer8 = "television saitama co., ltd." - val producer9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val producer1 = "group tac" - val producer2 = "nippon herald film group" - val producer3 = "the asahi shimbun company" - val producer4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddProducersVarargTests { - - @Test - fun `producers added are set to lower case`() { - // given - val producer = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(producer) - - // then - assertThat(anime.producers).containsExactly( - producer.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - " $expectedProducer", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `remove tailing whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "$expectedProducer ", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in producers`() { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "pony canyon", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add producers if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "$input $input", - ) - - // then - assertThat(anime.producers).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "pony${input}canyon", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "po${input}ny can${input}yon", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val producer = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers() - - // then - assertThat(anime.producers).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val producer1 = "a producer" - val producer2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer2, - producer1, - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val producer1 = "satelight" - val producer2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val producer1 = "gallop" - val producer2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "tokyo HQ gallop" - val producer4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val producer1 = "anime beans" - val producer2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val producer1 = "studio durian" - val producer2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val producer1 = "sotsu" - val producer2 = "square enix" - val producer3 = "tv aichi" - val producer4 = "tv osaka" - val producer5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val producer1 = "tokyo kids" - val producer2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val producer1 = "groove corporation" - val producer2 = "group tac" - val producer3 = "hitsuji no uta production committee" - val producer4 = "inter communications inc." - val producer5 = "kenmedia" - val producer6 = "ken groove" - val producer7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val producer1 = "kitty media" - val producer2 = "media blasters" - val producer3 = "on-lead" - val producer4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val producer1 = "009 re:cyborg production committee" - val producer2 = "amazonlaterna co,ltd." - val producer3 = "funimation" - val producer4 = "ishimori production" - val producer5 = "nippon television network corporation" - val producer6 = "production i.g" - val producer7 = "sanzigen" - val producer8 = "t-joy" - val producer9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val producer1 = "animation do" - val producer2 = "chu-2byo production partners" - val producer3 = "kyoto animation" - val producer4 = "lantis" - val producer5 = "pony canyon" - val producer6 = "rakuonsha" - val producer7 = "tbs" - val producer8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val producer1 = "chiba television" - val producer2 = "geneon universal entertainment" - val producer3 = "kbs kyoto" - val producer4 = "medil" - val producer5 = "mie television broadcasting" - val producer6 = "sun tv" - val producer7 = "television kanagawa" - val producer8 = "television saitama co., ltd." - val producer9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val producer1 = "group tac" - val producer2 = "nippon herald film group" - val producer3 = "the asahi shimbun company" - val producer4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _sources = hashSetOf( - URI("https://example.org/anime/1535"), - URI("https://myanimelist.net/anime/1535"), - ), - ) - - // when - anime.sources.clear() - - // then - assertThat(anime.sources).isNotEmpty() - } - - @Nested - inner class AddSourcesConstructorTests { - - @Test - fun `add source`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // then - assertThat(anime.sources).containsExactly(source) - } - - @Test - fun `cannot add duplicated source link`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - source, - ), - ) - - // then - assertThat(result.sources).containsExactly(source) - } - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - _relatedAnime = hashSetOf( - source, - ), - ) - - // then - assertThat(result.sources).containsExactly( - source, - ) - assertThat(result.relatedAnime).isEmpty() - } - - @Test - fun `don't remove related anime if activateChecks is false`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - _relatedAnime = hashSetOf( - source, - ), - activateChecks = false, - ) - - // then - assertThat(result.sources).containsExactly( - source, - ) - assertThat(result.relatedAnime).containsExactly( - source, - ) - } - } - - @Nested - inner class AddSourcesTests { - - @Test - fun `add source`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addSources(listOf( - source, - )) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `cannot add duplicated source link`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.addSources(listOf( - source, - )) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - source, - ), - ) - - // when - anime.addSources(listOf( - source, - )) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class AddSourcesVarargTests { - - @Test - fun `add source`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addSources( - source, - ) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `cannot add duplicated source link`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.addSources(source) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - source, - ), - ) - - // when - anime.addSources(source) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class RemoveSourceIfTests { - - @Test - fun `successfully remove source`() { - // given - val source = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.removeSourceIf { it.toString() == "https://myanimelist.net/anime/2994" } - - // then - assertThat(anime.sources).isEmpty() - } - - @Test - fun `don't remove anything if the condition doesn't match`() { - // given - val source = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.removeSourceIf { it.toString().contains("anidb.net") } - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Nested - inner class AddRelatedAnimeConstructorTests { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _relatedAnime = hashSetOf( - URI("https://example.org/anime/1535"), - URI("https://myanimelist.net/anime/1535"), - ), - ) - - // when - anime.relatedAnime.clear() - - // then - assertThat(anime.relatedAnime).isNotEmpty() - } - - @Test - fun `add related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - - // when - val result = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - relatedAnime, - ), - ) - - // then - assertThat(result.relatedAnime).containsExactly( - relatedAnime, - ) - } - - @Test - fun `cannot add duplicated link for related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - - // when - val result = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - relatedAnime, - relatedAnime, - ), - ) - - // then - assertThat(result.relatedAnime).containsExactly( - relatedAnime, - ) - } - - @Test - fun `cannot add a related anime if the links is already part of the sources`() { - // given - val link = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - link, - ), - _relatedAnime = hashSetOf( - link, - ), - ) - - // then - assertThat(result.relatedAnime).isEmpty() - } - - @Test - fun `doesn't remove sources from relatedAnimeif activateChecks is false`() { - // when - val obj = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/6351"), - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2167"), - URI("https://myanimelist.net/anime/6351"), - ), - activateChecks = false, - ) - - // then - assertThat(obj.relatedAnime).hasSize(2) - } - } - - @Nested - inner class AddRelatedAnimeTests { - - @Test - fun `add related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addRelatedAnime(listOf( - relatedAnime, - )) - - // then - assertThat(anime.relatedAnime).containsExactly( - relatedAnime, - ) - } - - @Test - fun `cannot add duplicated link for related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - relatedAnime, - ), - ) - - // when - anime.addRelatedAnime(listOf( - relatedAnime, - )) - - // then - assertThat(anime.relatedAnime).containsExactly( - relatedAnime, - ) - } - - @Test - fun `cannot add a related anime if the links is already part of the sources`() { - // given - val link = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - link, - ), - ) - - // when - anime.addRelatedAnime(listOf( - link, - )) - - // then - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class AddRelatedAnimeVarargTests { - - @Test - fun `add related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addRelatedAnime( - relatedAnime, - ) - - // then - assertThat(anime.relatedAnime).containsExactly( - relatedAnime, - ) - } - - @Test - fun `cannot add duplicated link for related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - relatedAnime, - ), - ) - - // when - anime.addRelatedAnime( - relatedAnime, - ) - - // then - assertThat(anime.relatedAnime).containsExactly( - relatedAnime, - ) - } - - @Test - fun `cannot add a related anime if the links is already part of the sources`() { - // given - val link = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - link, - ), - ) - - // when - anime.addRelatedAnime(link) - - // then - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class RemoveRelationIfTests { - - @Test - fun `successfully remove related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - relatedAnime, - ), - ) - - // when - anime.removeRelatedAnimeIf { it.toString() == "https://myanimelist.net/anime/2994" } - - // then - assertThat(anime.relatedAnime).isEmpty() - } - - @Test - fun `don't remove anything if condition doesn't match`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - relatedAnime, - ), - ) - - // when - anime.removeRelatedAnimeIf { it.toString().contains("anidb.net") } - - // then - assertThat(anime.relatedAnime).containsExactly( - relatedAnime, - ) - } - } - } - - @Nested - inner class EqualityTests { - - @Test - fun `is equal if titles are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - ) - - val b = AnimeRaw( - _title = title, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if titles are different`() { - // given - val a = AnimeRaw( - _title = "Death Note", - ) - - val b = AnimeRaw( - _title = "デスノート", - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if source links are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - ) - - val b = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if source links are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - ) - - val b = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - URI("https://anidb.net/anime/4563"), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if synonyms are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - val b = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if synonyms are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - val b = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - "Quaderno della Morte", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if types are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - type = TV, - ) - - val b = AnimeRaw( - _title = title, - type = TV, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if types are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - type = TV, - ) - - val b = AnimeRaw( - _title = title, - type = MOVIE, - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if episodes are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - episodes = 37, - ) - - val b = AnimeRaw( - _title = title, - episodes = 37, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if episodes are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - episodes = 37, - ) - - val b = AnimeRaw( - _title = title, - episodes = 1, - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if status is the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - status = FINISHED, - ) - - val b = AnimeRaw( - _title = title, - status = FINISHED, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if status is different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - status = FINISHED, - ) - - val b = AnimeRaw( - _title = title, - status = UNKNOWN_STATUS, - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if animeSeasons are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = FALL, - year = 2006, - ), - ) - - val b = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = FALL, - year = 2006, - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if animeSeasons are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = UNDEFINED, - year = 2006, - ), - ) - - val b = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = FALL, - year = 2006, - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if pictures are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - picture = URI("https://example.org/pic/1.png"), - ) - - val b = AnimeRaw( - _title = title, - picture = URI("https://example.org/pic/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if pictures are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - picture = URI("https://example.org/pictures/1.png"), - ) - - val b = AnimeRaw( - _title = title, - picture = URI("https://example.org/pic/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if thumbnail are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnail/1.png"), - ) - - val b = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnail/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if thumbnail are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnails/1.png"), - ) - - val b = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnail/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if durations are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - duration = Duration(20, MINUTES), - ) - - val b = AnimeRaw( - _title = title, - duration = Duration(20, MINUTES), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if durations are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - duration = Duration(21, MINUTES), - ) - - val b = AnimeRaw( - _title = title, - duration = Duration(20, MINUTES), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if scores are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - val b = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if scores are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - val b = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - MetaDataProviderScoreValue( - hostname = "other.com", - value = 9.50, - range = 1.0..10.0, - ) - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if related anime are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - ) - - val b = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if related anime are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - ) - - val b = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - URI("http://anilist.co/anime/2994"), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if tags are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _tags = hashSetOf( - "comedy", - "slice of life", - ), - ) - - val b = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - "comedy", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if tags are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - ), - ) - - val b = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - "comedy", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if studios are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if studios are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if producers are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if producers are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if the other object is of a different type`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - ), - ) - - // when - val result = a.equals(1) - - // then - assertThat(result).isFalse() - } - } - - @Nested - inner class MergeTests { - - @Test - fun `use this number of episodes` () { - // given - val anime = AnimeRaw( - _title = "this", - episodes = 12, - ) - val other = AnimeRaw( - _title = "other", - episodes = 13, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.episodes).isEqualTo(12) - } - - @Test - fun `use other's number of episodes if this number of episodes is 0` () { - // given - val anime = AnimeRaw( - _title = "this", - episodes = 0, - ) - val other = AnimeRaw( - _title = "other", - episodes = 13, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.episodes).isEqualTo(13) - } - - @Test - fun `use this type` () { - // given - val anime = AnimeRaw( - _title = "this", - type = MOVIE, - ) - val other = AnimeRaw( - _title = "other", - type = SPECIAL, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - - @Test - fun `use other's type if this type is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - type = UNKNOWN_TYPE, - ) - val other = AnimeRaw( - _title = "other", - type = SPECIAL, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - - @Test - fun `use this status` () { - // given - val anime = AnimeRaw( - _title = "this", - status = FINISHED, - ) - val other = AnimeRaw( - _title = "other", - status = ONGOING, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - - @Test - fun `use other's status if this status is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - status = UNKNOWN_STATUS, - ) - val other = AnimeRaw( - _title = "other", - status = ONGOING, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - - @Test - fun `use this duration` () { - // given - val anime = AnimeRaw( - _title = "this", - duration = Duration(120, MINUTES), - ) - val other = AnimeRaw( - _title = "other", - duration = Duration(125, MINUTES), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.duration).isEqualTo(Duration(120, MINUTES)) - } - - @Test - fun `use other's duration if this duration is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - duration = UNKNOWN_DURATION, - ) - val other = AnimeRaw( - _title = "other", - duration = Duration(125, MINUTES), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.duration).isEqualTo(Duration(125, MINUTES)) - } - - @Test - fun `use this season` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = FALL, - year = 2010, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - assertThat(result.animeSeason.year).isEqualTo(2010) - } - - @Test - fun `use other's season if this season is UNDEFINED` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = UNDEFINED, - year = 2010, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - assertThat(result.animeSeason.year).isEqualTo(2010) - } - - @Test - fun `use this year` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = FALL, - year = 2010, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - assertThat(result.animeSeason.year).isEqualTo(2010) - } - - @Test - fun `use other's year if this year is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - assertThat(result.animeSeason.year).isEqualTo(2011) - } - - @Test - fun `add title and synonyms of the other anime to this anime's synonyms`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - val other = AnimeRaw( - _title = "DEATH NOTE", - _synonyms = hashSetOf( - "Caderno da Morte", - "Quaderno della Morte", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.title).isEqualTo(anime.title) - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Caderno da Morte", - "DEATH NOTE", - "Quaderno della Morte", - ) - } - - @Test - fun `merge related anime and source links`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _relatedAnime = hashSetOf( - URI("https://anidb.net/anime/8146"), - URI("https://anidb.net/anime/8147"), - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.sources).containsExactlyInAnyOrder( - URI("https://anidb.net/anime/4563"), - URI("https://myanimelist.net/anime/1535"), - ) - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://anidb.net/anime/8146"), - URI("https://anidb.net/anime/8147"), - URI("https://myanimelist.net/anime/2994"), - ) - } - - @Test - fun `merge tags`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _tags = hashSetOf( - "Psychological", - "Thriller", - "Shounen", - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _tags = hashSetOf( - "Mystery", - "Police", - "Psychological", - "Supernatural", - "Thriller", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "mystery", - "police", - "psychological", - "shounen", - "supernatural", - "thriller", - ) - } - - @Test - fun `merge studios`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _studios = hashSetOf( - "studio 1" - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _studios = hashSetOf( - "studio 2", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "studio 1", - "studio 2", - ) - } - - @Test - fun `merge producers`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _producers = hashSetOf( - "producer 1" - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _producers = hashSetOf( - "producer 2", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "producer 1", - "producer 2", - ) - } - - @Test - fun `merge scores`() { - // given - val title = "Death Note" - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val a = AnimeRaw( - _title = title, - ).addScores( - scoreA, - ) - - val scoreB = MetaDataProviderScoreValue( - hostname = "other.com", - value = 9.5, - range = 1.0..10.0, - ) - val b = AnimeRaw( - _title = title, - ).addScores( - scoreB, - ) - - // when - val result = a.mergeWith(b) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreA, - scoreB, - ) - } - - @Test - fun `overrides score entries with same hostname`() { - // given - val title = "Death Note" - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 2.41, - range = 1.0..5.0, - ) - val a = AnimeRaw( - _title = title, - ).addScores( - scoreA, - ) - - val scoreB = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val b = AnimeRaw( - _title = title, - ).addScores( - scoreB, - ) - - // when - val result = a.mergeWith(b) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreB, - ) - } - } - - @Nested - inner class TagsTest { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _tags = hashSetOf( - "thriller", - "acion", - ), - ) - - // when - anime.tags.clear() - - // then - assertThat(anime.tags).isNotEmpty() - } - - @Nested - inner class AddTagsConstructorTests { - - @Test - fun `tags added by constructor are set to lower case`() { - // given - val tag = "EXAMPLE" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - tag, - ), - ) - - // then - assertThat(result.tags).containsExactly( - tag.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from tags`() { - // given - val expectedTag = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - " $expectedTag", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `remove tailing whitespace from tags`() { - // given - val expectedTag = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "$expectedTag ", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in tags`() { - // given - val expectedTag = "slice of life" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "slice of life", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank tag`(input: String) { - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.tags).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTag = "slice of life" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "slice${input}of${input}life", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTag = "slice of life" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "sli${input}ce of li${input}fe", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `prevent duplicates`() { - // given - val tag1 = "a tag" - val tag2 = "before the other" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - tag2, - tag1, - tag1, - tag2, - ), - ) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - tag1, - tag2, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _tags = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _tags = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) - fun `doesn't fix tags if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - value, - ), - activateChecks = false, - ) - - // then - assertThat(obj.tags).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddTagsTests { - - @Test - fun `tags added are set to lower case`() { - // given - val tag = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - tag, - )) - - // then - assertThat(anime.tags).containsExactly( - tag.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - " $expectedTag", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `remove tailing whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "$expectedTag ", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in tags`() { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "slice of life", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank tag`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "$input $input", - )) - - // then - assertThat(anime.tags).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "slice${input}of${input}life", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "sli${input}ce o${input}f li${input}fe", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `prevent duplicates`() { - // given - val tag1 = "a tag" - val tag2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - tag2, - tag1, - tag1, - tag2, - )) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - tag1, - tag2, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddTagsVarargTests { - - @Test - fun `tags added are set to lower case`() { - // given - val tag = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addTags(tag) - - // then - assertThat(anime.tags).containsExactly( - tag.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - " $expectedTag", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `remove tailing whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "$expectedTag ", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in tags`() { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "slice of life", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank tag`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "$input $input", - ) - - // then - assertThat(anime.tags).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "slice${input}of${input}life", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "sli${input}ce o${input}f li${input}fe", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `prevent duplicates`() { - // given - val tag1 = "a tag" - val tag2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - tag2, - tag1, - tag1, - tag2, - ) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - tag1, - tag2, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `default duration is 0 seconds`() { - // given - val expectedDuration = Duration(0, SECONDS) - - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.duration).isEqualTo(expectedDuration) - } - - @Test - fun `setting a duration of 10 seconds`() { - // when - val result = Duration(10, SECONDS) - - // then - assertThat(result.duration).isEqualTo(10) - assertThat(result.toString()).isEqualTo("10 seconds") - } - - @Test - fun `duration of a minute is equal to a duration of 60 seconds`() { - // given - val durationInSeconds = Duration(60, SECONDS) - - // when - val result = Duration(1, MINUTES) - - // then - assertThat(result).isEqualTo(durationInSeconds) - assertThat(result.duration).isEqualTo(60) - assertThat(result.toString()).isEqualTo("60 seconds") - } - - @Test - fun `duration of an hour is equal to a duration of 60 minutes`() { - // given - val durationInMinutes = Duration(60, MINUTES) - - // when - val result = Duration(1, HOURS) - - // then - assertThat(result).isEqualTo(durationInMinutes) - assertThat(result.duration).isEqualTo(3600) - assertThat(result.toString()).isEqualTo("3600 seconds") - } - - @Test - fun `returns false on equals check if other object is not of the same type`() { - // given - val duration = Duration(60, MINUTES) - - // when - val result = duration.equals(EMPTY) - - // then - assertThat(result).isFalse() - } - } - - @Nested - inner class AnimeSeasonTests { - - @Test - fun `default year is 0 indicating unknown and season is undefined`() { - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.animeSeason.year).isZero() - assertThat(result.animeSeason.isYearOfPremiereUnknown()).isTrue() - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Nested - inner class SeasonTests { - - @Test - fun `default season if nothing has been set`() { - // when - val result = AnimeRaw("test") - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - assertThat(result.animeSeason.year).isZero() - } - } - - @Nested - inner class ToStringTests { - - @Test - fun `create formatted string listing all properties`() { - // when - val result = TestAnimeRawObjects.AllPropertiesSet.obj.toString() - - // then - assertThat(result).isEqualTo( - """ - AnimeRaw( - sources = [https://anilist.co/anime/177191] - title = Go-toubun no Hanayome * - type = SPECIAL - episodes = 2 - status = FINISHED - animeSeason = AnimeSeason(season=FALL, year=2024) - picture = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg - thumbnail = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg - duration = 1440 seconds - scores = [MetaDataProviderScoreValue(hostname=anilist.co, value=75.0, range=1.0..100.0)] - synonyms = [The Quintessential Quintuplets*, 五等分の花嫁*] - studios = [bibury animation studios] - producers = [dax production, nichion, pony canyon] - relatedAnime = [https://anilist.co/anime/131520] - tags = [comedy, drama, ensemble cast, female harem, heterosexual, language barrier, male protagonist, marriage, primarily female cast, romance, school, shounen, twins] - ) - """.trimIndent() - ) - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `default value is 0`() { - // when - val result = AnimeRaw("test") - - // then - assertThat(result.episodes).isZero() - } - - @Test - fun `throws exception if number of episodes is negative`() { - // when - val result = assertThrows<IllegalArgumentException> { - AnimeRaw( - _title = "test", - episodes = -1, - ) - } - - // then - assertThat(result).hasMessage("Episodes cannot have a negative value.") - } - - @Test - fun `doesn't throw an exception if number of episodes is negative and activateChecks is false`() { - // when - val result = AnimeRaw( - _title = "test", - episodes = -1, - activateChecks = false, - ) - - // then - assertThat(result.episodes).isEqualTo(-1) - } - } - - @Nested - inner class PerformChecksTests { - - @Nested - inner class TitleTests { - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) - fun `fixes title`(value: String) { - // given - val obj = AnimeRaw( - _title = value, - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "Death Note", - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - } - - @Nested - inner class EpisodeTests { - - @Test - fun `throws exception if number of episodes is negative`() { - val anime = AnimeRaw( - _title = "test", - episodes = -1, - activateChecks = false, - ) - - // when - val result = assertThrows<IllegalArgumentException> { - anime.performChecks() - } - - // then - assertThat(result).hasMessage("Episodes cannot have a negative value.") - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `removes sources from relatedAnime`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/6351"), - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2167"), - URI("https://myanimelist.net/anime/6351"), - ), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(""" - { - "sources": [ - "https://myanimelist.net/anime/6351" - ], - "title": "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED" - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [ - "https://myanimelist.net/anime/2167" - ], - "tags": [] - } - """.trimIndent()) - } - } - - @Nested - inner class SynonymsTests { - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) - fun `fixes synonyms`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - "Death Note", - ), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - - @ParameterizedTest - @ValueSource(strings = ["", " ", " ", "\u200C"]) - fun `removes blank entries from synonyms`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _synonyms = HashSet(), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - } - - @Nested - inner class TagsTests { - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE"]) - fun `fixes tags`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - "death note", - ), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - - @ParameterizedTest - @ValueSource(strings = ["", " ", " ", "\u200C"]) - fun `removes blank entries from tags`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _tags = HashSet(), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - _relatedAnime = hashSetOf( - source, - ), - activateChecks = false, - ) - - // when - anime.performChecks() - - // then - assertThat(anime.sources).containsExactly(source) - assertThat(anime.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `default is empty`() { - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.scores).isEmpty() - } - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - // when - anime.scores.clear() - - // then - assertThat(anime.scores).isNotEmpty() - } - - @Nested - inner class AddTagsTests { - - @Test - fun `ignores NoMetaDataProviderScore`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - val result = anime.addScores( - NoMetaDataProviderScore, - ) - - // then - assertThat(result.scores).isEmpty() - } - - @Test - fun `correctly adds score`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - score, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - - @Test - fun `correctly adds multiple scores`() { - // given - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val scoreB = MetaDataProviderScoreValue( - hostname = "other.com", - value = 6.83, - range = 1.0..10.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - scoreA, - scoreB, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreA, - scoreB, - ) - } - - @Test - fun `overrides existing score if the hostname is identical`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 2.54, - range = 1.0..5.0, - ), - ) - - // when - val result = anime.addScores( - score, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - } - - @Nested - inner class AddTagsVarargTests { - - @Test - fun `ignores NoMetaDataProviderScore`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - val result = anime.addScores( - listOf( - NoMetaDataProviderScore, - ) - ) - - // then - assertThat(result.scores).isEmpty() - } - - @Test - fun `correctly adds score`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - listOf( - score, - ) - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - - @Test - fun `correctly adds multiple scores`() { - // given - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val scoreB = MetaDataProviderScoreValue( - hostname = "other.com", - value = 6.83, - range = 1.0..10.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - listOf( - scoreA, - scoreB, - ) - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreA, - scoreB, - ) - } - - @Test - fun `overrides existing score if the hostname is identical`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ).addScores( - listOf( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 2.54, - range = 1.0..5.0, - ), - ) - ) - - // when - val result = anime.addScores( - score, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.anime + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.TestAnimeRawObjects +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeStatus.ONGOING +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.json.AnimeRawAdapter +import io.github.manamiproject.modb.test.exceptionExpected +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeRawTest { + + @Nested + inner class TitleTests { + + @Test + fun `remove leading whitespace from title`() { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw(" $expectedTitle") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @Test + fun `remove tailing whitespace from title`() { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw("$expectedTitle ") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in title`() { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw("Death${input}Note") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // when + val result = AnimeRaw("Ba${input}ek") + + // then + assertThat(result.title).isEqualTo("Baek") + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `throws exception if title is blank`(value: String) { + // when + val result = assertThrows<IllegalArgumentException> { + AnimeRaw(value) + } + + // then + assertThat(result).hasMessage("Title cannot be blank.") + } + + @Test + fun `don't change title if activateChecks is set to false`() { + // given + val expectedTitle = " Death Note " + + // when + val result = AnimeRaw( + _title = expectedTitle, + activateChecks = false, + ) + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `throws an exception if title is blank even if activateChecks is set to false`(value: String) { + // when + val result = exceptionExpected<IllegalArgumentException> { + AnimeRaw( + _title = value, + activateChecks = false, + ) + } + + // then + assertThat(result).hasMessage("Title cannot be blank.") + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _synonyms = hashSetOf( + "other title1", + "other title2", + ), + ) + + // when + anime.synonyms.clear() + + // then + assertThat(anime.synonyms).isNotEmpty() + } + + @Nested + inner class AddSynonymsConstructorTests { + + @Test + fun `must not add a synonym if it equals the title`() { + // given + val title = "Death Note" + + // when + val result = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + title, + ), + ) + + // then + assertThat(result.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank synonym`(input: String) { + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Caderno${input}da${input}Morte", + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Cader${input}no da Mor${input}te", + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @Test + fun `remove leading whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + + // when + val result = AnimeRaw( + _title = "Title", + _synonyms = hashSetOf( + " $expectedTitleOne", + " $expectedTitleTwo", + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove tailing whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + + // when + val result = AnimeRaw( + _title = "Title", + _synonyms = hashSetOf( + "$expectedTitleOne ", + "$expectedTitleTwo ", + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + + // when + val result = AnimeRaw( + _title = "Title", + _synonyms = hashSetOf( + "Death Note", + "Made in Abyss", + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `successfully add a synonym`() { + // given + val synonym = "Caderno da Morte" + + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + synonym, + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + synonym, + ) + } + + @Test + fun `must not add a duplicated synonym`() { + // given + val synonym = "Caderno da Morte" + + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + synonym, + synonym, + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + synonym, + ) + } + + @Test + fun `synonym comparison to title is not case-sensitive`() { + // given + val title = "Death Note" + + // when + val result = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + title.uppercase(), + title.lowercase(), + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + title.uppercase(), + title.lowercase(), + ) + } + + @Test + fun `synonym comparison is not case-sensitive`() { + // given + val title = "Death Note" + + // when + val result = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + title, + title.lowercase(), + title.uppercase(), + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + title, + title.uppercase(), + title.lowercase(), + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `correctly normalized usages of apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _synonyms = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + "she\u2019s, John\u2019s, it\u2019s", + "the girls\u2019, the dogs\u2019", + "don\u2019t, can\u2019t", + "I\u2019m", + "I\u2019d, they\u2019d", + "rock \u2019n\u2019 roll", + "he\u2019ll, we\u2019ll", + "your\u2019re", + "I\u2019ve", + "\u2019ere", + "\u2019cause", + "\u2019bout", + "\u2019fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _synonyms = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "", " ", " ", "\u200C"]) + fun `doesn't fix synonyms if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf(value), + activateChecks = false, + ) + + // then + assertThat(obj.synonyms).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddSynonymsTests { + + @Test + fun `must not add a synonym if it equals the title`() { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms(listOf( + "Death Note", + )) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank synonym`(input: String) { + // given + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addSynonyms(listOf( + "$input $input", + )) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Caderno da Morte" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "Death${input}Note", + "Caderno${input}da${input}Morte", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Caderno da Morte" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "Death Note", + "Cader${input}no da Mor${input}te", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove leading whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + " $expectedTitleOne", + " $expectedTitleTwo", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove tailing whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "$expectedTitleOne ", + "$expectedTitleTwo ", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "Death Note", + "Made in Abyss", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `successfully add a list of synonyms`() { + // given + val one = "Caderno da Morte" + val two = "DN" + val three = "Quaderno della Morte" + val four = "Sveska Smrti" + + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms(listOf( + four, + two, + three, + one, + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + three, + four, + ) + } + + @Test + fun `must not add a duplicated synonym`() { + // given + val one = "Caderno da Morte" + val two = "DN" + + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + two, + ), + ) + + // when + anime.addSynonyms(listOf( + two, + one, + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + ) + } + + @Test + fun `synonym comparison to title is not case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw(title) + + // when + anime.addSynonyms(listOf( + title.uppercase(), + title.lowercase(), + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title.uppercase(), + title.lowercase(), + ) + } + + @Test + fun `synonym comparison is not case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + title.lowercase(), + title.uppercase(), + ), + ) + + // when + anime.addSynonyms(title) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title, + title.uppercase(), + title.lowercase(), + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she\u2019s, John\u2019s, it\u2019s", + "the girls\u2019, the dogs\u2019", + "don\u2019t, can\u2019t", + "I\u2019m", + "I\u2019d, they\u2019d", + "rock \u2019n\u2019 roll", + "he\u2019ll, we\u2019ll", + "your\u2019re", + "I\u2019ve", + "\u2019ere", + "\u2019cause", + "\u2019bout", + "\u2019fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddSynonymsVarargTests { + + @Test + fun `must not add a synonym if it equals the title`() { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "Death Note", + ) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank synonym`(input: String) { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "$input $input", + ) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "Caderno${input}da${input}Morte", + ) + + // then + assertThat(anime.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "Cader${input}no da Mor${input}te", + ) + + // then + assertThat(anime.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @Test + fun `remove leading whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms( + " $expectedTitleOne", + " $expectedTitleTwo", + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove tailing whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms( + "$expectedTitleOne ", + "$expectedTitleTwo ", + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms( + "Death Note", + "Made in Abyss", + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `successfully add synonyms`() { + // given + val one = "Caderno da Morte" + val two = "DN" + val three = "Quaderno della Morte" + val four = "Sveska Smrti" + + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + four, + two, + three, + one, + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + three, + four, + ) + } + + @Test + fun `must not add a duplicated synonym`() { + // given + val one = "Caderno da Morte" + val two = "DN" + + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + two, + ), + ) + + // when + anime.addSynonyms( + two, + one, + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + ) + } + + @Test + fun `synonym comparison to title is case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw(title) + + // when + anime.addSynonyms( + title.uppercase(), + title.lowercase(), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title.uppercase(), + title.lowercase(), + ) + } + + @Test + fun `synonym comparison is not case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw("デスノート") + + // when + anime.addSynonyms( + title, + title.lowercase(), + title.uppercase(), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title, + title.uppercase(), + title.lowercase(), + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she\u2019s, John\u2019s, it\u2019s", + "the girls\u2019, the dogs\u2019", + "don\u2019t, can\u2019t", + "I\u2019m", + "I\u2019d, they\u2019d", + "rock \u2019n\u2019 roll", + "he\u2019ll, we\u2019ll", + "your\u2019re", + "I\u2019ve", + "\u2019ere", + "\u2019cause", + "\u2019bout", + "\u2019fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class RemoveSynonymIfTests { + + @Test + fun `successfully remove synonym`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Notatnik smierci", + ), + ) + + // when + anime.removeSynonymIf { it == "Notatnik smierci" } + + // then + assertThat(anime.synonyms).isEmpty() + } + + @Test + fun `don't remove anything if condition doesn't match`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Notatnik smierci", + ), + ) + + // when + anime.removeSynonymIf { it.contains("Bleach") } + + // then + assertThat(anime.synonyms).containsExactly( + "Notatnik smierci", + ) + } + } + } + + @Nested + inner class StudiosTest { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + // when + anime.studios.clear() + + // then + assertThat(anime.studios).isNotEmpty() + } + + @Nested + inner class AddStudiosConstructorTests { + + @Test + fun `studios added by constructor are set to lower case`() { + // given + val studio = "EXAMPLE" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio, + ), + ) + + // then + assertThat(result.studios).containsExactly( + studio.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from studios`() { + // given + val expectedStudio = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + " $expectedStudio", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `remove tailing whitespace from studios`() { + // given + val expectedStudio = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "$expectedStudio ", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in studios`() { + // given + val expectedStudio = "bibury animation studios" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "bibury animation studios", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.studios).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedStudio = "bibury animation studios" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "bibury${input}animation${input}studios", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedStudio = "bibury animation studios" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "bi${input}bury ani${input}mation stu${input}dios", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val studio = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf(), + ) + + // then + assertThat(result.studios).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val studio1 = "a studio" + val studio2 = "before the other" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val studio1 = "satelight" + val studio2 = "satelight inc." + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val studio1 = "gallop" + val studio2 = "studio gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + studio3, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "tokyo HQ gallop" + val studio4 = "gallop made up" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + studio3, + studio4, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val studio1 = "anime beans" + val studio2 = "tate anime" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val studio1 = "studio durian" + val studio2 = "wit studio" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val studio1 = "sotsu" + val studio2 = "square enix" + val studio3 = "tv aichi" + val studio4 = "tv osaka" + val studio5 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val studio1 = "tokyo kids" + val studio2 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val studio1 = "groove corporation" + val studio2 = "group tac" + val studio3 = "hitsuji no uta production committee" + val studio4 = "inter communications inc." + val studio5 = "ken groove" + val studio6 = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val studio1 = "kitty media" + val studio2 = "media blasters" + val studio3 = "on-lead" + val studio4 = "venet" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val studio1 = "009 re:cyborg production committee" + val studio2 = "amazonlaterna co,ltd." + val studio3 = "funimation" + val studio4 = "ishimori production" + val studio5 = "nippon television network corporation" + val studio6 = "production i.g" + val studio7 = "sanzigen" + val studio8 = "t-joy" + val studio9 = "vap" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val studio1 = "animation do" + val studio2 = "chu-2byo production partners" + val studio3 = "kyoto animation" + val studio4 = "lantis" + val studio5 = "pony canyon" + val studio6 = "rakuonsha" + val studio7 = "tbs" + val studio8 = "tokyo broadcasting system" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val studio1 = "chiba television" + val studio2 = "geneon universal entertainment" + val studio3 = "kbs kyoto" + val studio4 = "medil" + val studio5 = "mie television broadcasting" + val studio6 = "sun tv" + val studio7 = "television kanagawa" + val studio8 = "television saitama co., ltd." + val studio9 = "thefool" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val studio1 = "group tac" + val studio2 = "nippon herald film group" + val studio3 = "the asahi shimbun company" + val studio4 = "tv asahi" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _studios = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _studios = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) + fun `doesn't fix studios if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _studios = hashSetOf( + value, + ), + activateChecks = false, + ) + + // then + assertThat(obj.studios).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddStudiosTests { + + @Test + fun `studios added are set to lower case`() { + // given + val studio = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio, + )) + + // then + assertThat(anime.studios).containsExactly( + studio.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from studios`() { + // given + val expectedStudio = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + " $expectedStudio", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `remove tailing whitespace from studios`() { + // given + val expectedStudios = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "$expectedStudios ", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudios, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in studios`() { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "bibury animation studios", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "$input $input", + )) + + // then + assertThat(anime.studios).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "bibury${input}animation${input}studios", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "bi${input}bury ani${input}mation stu${input}dios", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val studio = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf()) + + // then + assertThat(anime.studios).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val studio1 = "a studio" + val studio2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val studio1 = "satelight" + val studio2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val studio1 = "gallop" + val studio2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + studio3, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "tokyo HQ gallop" + val studio4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val studio1 = "anime beans" + val studio2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val studio1 = "studio durian" + val studio2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val studio1 = "sotsu" + val studio2 = "square enix" + val studio3 = "tv aichi" + val studio4 = "tv osaka" + val studio5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val studio1 = "tokyo kids" + val studio2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val studio1 = "groove corporation" + val studio2 = "group tac" + val studio3 = "hitsuji no uta production committee" + val studio4 = "inter communications inc." + val studio5 = "kenmedia" + val studio6 = "ken groove" + val studio7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val studio1 = "kitty media" + val studio2 = "media blasters" + val studio3 = "on-lead" + val studio4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val studio1 = "009 re:cyborg production committee" + val studio2 = "amazonlaterna co,ltd." + val studio3 = "funimation" + val studio4 = "ishimori production" + val studio5 = "nippon television network corporation" + val studio6 = "production i.g" + val studio7 = "sanzigen" + val studio8 = "t-joy" + val studio9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val studio1 = "animation do" + val studio2 = "chu-2byo production partners" + val studio3 = "kyoto animation" + val studio4 = "lantis" + val studio5 = "pony canyon" + val studio6 = "rakuonsha" + val studio7 = "tbs" + val studio8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val studio1 = "chiba television" + val studio2 = "geneon universal entertainment" + val studio3 = "kbs kyoto" + val studio4 = "medil" + val studio5 = "mie television broadcasting" + val studio6 = "sun tv" + val studio7 = "television kanagawa" + val studio8 = "television saitama co., ltd." + val studio9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val studio1 = "group tac" + val studio2 = "nippon herald film group" + val studio3 = "the asahi shimbun company" + val studio4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios(listOf( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddStudiosVarargTests { + + @Test + fun `studios added are set to lower case`() { + // given + val studio = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(studio) + + // then + assertThat(anime.studios).containsExactly( + studio.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from studios`() { + // given + val expectedStudio = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + " $expectedStudio", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `remove tailing whitespace from studios`() { + // given + val expectedStudio = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "$expectedStudio ", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in studios`() { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "bibury animation studios", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "$input $input", + ) + + // then + assertThat(anime.studios).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "bibury${input}animation${input}studios", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "bi${input}bury ani${input}mation stu${input}dios", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val studio = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios() + + // then + assertThat(anime.studios).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val studio1 = "a studio" + val studio2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio2, + studio1, + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val studio1 = "satelight" + val studio2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val studio1 = "gallop" + val studio2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio3, + studio2, + studio1, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "tokyo HQ gallop" + val studio4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val studio1 = "anime beans" + val studio2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val studio1 = "studio durian" + val studio2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val studio1 = "sotsu" + val studio2 = "square enix" + val studio3 = "tv aichi" + val studio4 = "tv osaka" + val studio5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val studio1 = "tokyo kids" + val studio2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val studio1 = "groove corporation" + val studio2 = "group tac" + val studio3 = "hitsuji no uta production committee" + val studio4 = "inter communications inc." + val studio5 = "kenmedia" + val studio6 = "ken groove" + val studio7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val studio1 = "kitty media" + val studio2 = "media blasters" + val studio3 = "on-lead" + val studio4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val studio1 = "009 re:cyborg production committee" + val studio2 = "amazonlaterna co,ltd." + val studio3 = "funimation" + val studio4 = "ishimori production" + val studio5 = "nippon television network corporation" + val studio6 = "production i.g" + val studio7 = "sanzigen" + val studio8 = "t-joy" + val studio9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val studio1 = "animation do" + val studio2 = "chu-2byo production partners" + val studio3 = "kyoto animation" + val studio4 = "lantis" + val studio5 = "pony canyon" + val studio6 = "rakuonsha" + val studio7 = "tbs" + val studio8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val studio1 = "chiba television" + val studio2 = "geneon universal entertainment" + val studio3 = "kbs kyoto" + val studio4 = "medil" + val studio5 = "mie television broadcasting" + val studio6 = "sun tv" + val studio7 = "television kanagawa" + val studio8 = "television saitama co., ltd." + val studio9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val studio1 = "group tac" + val studio2 = "nippon herald film group" + val studio3 = "the asahi shimbun company" + val studio4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + } + + @Nested + inner class ProducersTest { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + // when + anime.producers.clear() + + // then + assertThat(anime.producers).isNotEmpty() + } + + @Nested + inner class AddProducersConstructorTests { + + @Test + fun `producers added by constructor are set to lower case`() { + // given + val producer = "EXAMPLE" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer, + ), + ) + + // then + assertThat(result.producers).containsExactly( + producer.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from producers`() { + // given + val expectedProducer = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + " $expectedProducer", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `remove tailing whitespace from producers`() { + // given + val expectedProducer = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "$expectedProducer ", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in producers`() { + // given + val expectedProducer = "pony canyon" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "pony canyon", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add producer if it's a blank string`(input: String) { + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.producers).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedProducer = "pony canyon" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "pony${input}canyon", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedProducer = "pony canyon" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "po${input}ny can${input}yon", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val producer = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf(), + ) + + // then + assertThat(result.producers).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val producer1 = "a producer" + val producer2 = "before the other" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer1, + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val producer1 = "satelight" + val producer2 = "satelight inc." + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer1, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val producer1 = "gallop" + val producer2 = "studio gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer1, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer3, + producer1, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "tokyo HQ gallop" + val producer4 = "gallop made up" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val producer1 = "anime beans" + val producer2 = "tate anime" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val producer1 = "studio durian" + val producer2 = "wit studio" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val producer1 = "sotsu" + val producer2 = "square enix" + val producer3 = "tv aichi" + val producer4 = "tv osaka" + val producer5 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val producer1 = "tokyo kids" + val producer2 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val producer1 = "groove corporation" + val producer2 = "group tac" + val producer3 = "hitsuji no uta production committee" + val producer4 = "inter communications inc." + val producer5 = "kenmedia" + val producer6 = "ken groove" + val producer7 = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val producer1 = "kitty media" + val producer2 = "media blasters" + val producer3 = "on-lead" + val producer4 = "venet" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val producer1 = "009 re:cyborg production committee" + val producer2 = "amazonlaterna co,ltd." + val producer3 = "funimation" + val producer4 = "ishimori production" + val producer5 = "nippon television network corporation" + val producer6 = "production i.g" + val producer7 = "sanzigen" + val producer8 = "t-joy" + val producer9 = "vap" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val producer1 = "animation do" + val producer2 = "chu-2byo production partners" + val producer3 = "kyoto animation" + val producer4 = "lantis" + val producer5 = "pony canyon" + val producer6 = "rakuonsha" + val producer7 = "tbs" + val producer8 = "tokyo broadcasting system" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val producer1 = "chiba television" + val producer2 = "geneon universal entertainment" + val producer3 = "kbs kyoto" + val producer4 = "medil" + val producer5 = "mie television broadcasting" + val producer6 = "sun tv" + val producer7 = "television kanagawa" + val producer8 = "television saitama co., ltd." + val producer9 = "thefool" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val producer1 = "group tac" + val producer2 = "nippon herald film group" + val producer3 = "the asahi shimbun company" + val producer4 = "tv asahi" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _producers = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _producers = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) + fun `doesn't fix producers if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _producers = hashSetOf( + value, + ), + activateChecks = false, + ) + + // then + assertThat(obj.producers).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddProducersTests { + + @Test + fun `producers added are set to lower case`() { + // given + val producer = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer, + )) + + // then + assertThat(anime.producers).containsExactly( + producer.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + " $expectedProducer", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `remove tailing whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "$expectedProducer ", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in producers`() { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "pony canyon", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "$input $input", + )) + + // then + assertThat(anime.producers).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "pony${input}canyon", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "po${input}ny can${input}yon", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val producer = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf()) + + // then + assertThat(anime.producers).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val producer1 = "a producer" + val producer2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer2, + producer1, + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val producer1 = "satelight" + val producer2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val producer1 = "gallop" + val producer2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer2, + producer3, + producer1, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "tokyo HQ gallop" + val producer4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val producer1 = "anime beans" + val producer2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val producer1 = "studio durian" + val producer2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val producer1 = "sotsu" + val producer2 = "square enix" + val producer3 = "tv aichi" + val producer4 = "tv osaka" + val producer5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val producer1 = "tokyo kids" + val producer2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val producer1 = "groove corporation" + val producer2 = "group tac" + val producer3 = "hitsuji no uta production committee" + val producer4 = "inter communications inc." + val producer5 = "kenmedia" + val producer6 = "ken groove" + val producer7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val producer1 = "kitty media" + val producer2 = "media blasters" + val producer3 = "on-lead" + val producer4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val producer1 = "009 re:cyborg production committee" + val producer2 = "amazonlaterna co,ltd." + val producer3 = "funimation" + val producer4 = "ishimori production" + val producer5 = "nippon television network corporation" + val producer6 = "production i.g" + val producer7 = "sanzigen" + val producer8 = "t-joy" + val producer9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val producer1 = "animation do" + val producer2 = "chu-2byo production partners" + val producer3 = "kyoto animation" + val producer4 = "lantis" + val producer5 = "pony canyon" + val producer6 = "rakuonsha" + val producer7 = "tbs" + val producer8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val producer1 = "chiba television" + val producer2 = "geneon universal entertainment" + val producer3 = "kbs kyoto" + val producer4 = "medil" + val producer5 = "mie television broadcasting" + val producer6 = "sun tv" + val producer7 = "television kanagawa" + val producer8 = "television saitama co., ltd." + val producer9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val producer1 = "group tac" + val producer2 = "nippon herald film group" + val producer3 = "the asahi shimbun company" + val producer4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddProducersVarargTests { + + @Test + fun `producers added are set to lower case`() { + // given + val producer = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(producer) + + // then + assertThat(anime.producers).containsExactly( + producer.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + " $expectedProducer", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `remove tailing whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "$expectedProducer ", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in producers`() { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "pony canyon", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add producers if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "$input $input", + ) + + // then + assertThat(anime.producers).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "pony${input}canyon", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "po${input}ny can${input}yon", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val producer = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers() + + // then + assertThat(anime.producers).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val producer1 = "a producer" + val producer2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer2, + producer1, + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val producer1 = "satelight" + val producer2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val producer1 = "gallop" + val producer2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "tokyo HQ gallop" + val producer4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val producer1 = "anime beans" + val producer2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val producer1 = "studio durian" + val producer2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val producer1 = "sotsu" + val producer2 = "square enix" + val producer3 = "tv aichi" + val producer4 = "tv osaka" + val producer5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val producer1 = "tokyo kids" + val producer2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val producer1 = "groove corporation" + val producer2 = "group tac" + val producer3 = "hitsuji no uta production committee" + val producer4 = "inter communications inc." + val producer5 = "kenmedia" + val producer6 = "ken groove" + val producer7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val producer1 = "kitty media" + val producer2 = "media blasters" + val producer3 = "on-lead" + val producer4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val producer1 = "009 re:cyborg production committee" + val producer2 = "amazonlaterna co,ltd." + val producer3 = "funimation" + val producer4 = "ishimori production" + val producer5 = "nippon television network corporation" + val producer6 = "production i.g" + val producer7 = "sanzigen" + val producer8 = "t-joy" + val producer9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val producer1 = "animation do" + val producer2 = "chu-2byo production partners" + val producer3 = "kyoto animation" + val producer4 = "lantis" + val producer5 = "pony canyon" + val producer6 = "rakuonsha" + val producer7 = "tbs" + val producer8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val producer1 = "chiba television" + val producer2 = "geneon universal entertainment" + val producer3 = "kbs kyoto" + val producer4 = "medil" + val producer5 = "mie television broadcasting" + val producer6 = "sun tv" + val producer7 = "television kanagawa" + val producer8 = "television saitama co., ltd." + val producer9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val producer1 = "group tac" + val producer2 = "nippon herald film group" + val producer3 = "the asahi shimbun company" + val producer4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _sources = hashSetOf( + URI("https://example.org/anime/1535"), + URI("https://myanimelist.net/anime/1535"), + ), + ) + + // when + anime.sources.clear() + + // then + assertThat(anime.sources).isNotEmpty() + } + + @Nested + inner class AddSourcesConstructorTests { + + @Test + fun `add source`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // then + assertThat(anime.sources).containsExactly(source) + } + + @Test + fun `cannot add duplicated source link`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + source, + ), + ) + + // then + assertThat(result.sources).containsExactly(source) + } + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + ) + + // then + assertThat(result.sources).containsExactly( + source, + ) + assertThat(result.relatedAnime).isEmpty() + } + + @Test + fun `don't remove related anime if activateChecks is false`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + activateChecks = false, + ) + + // then + assertThat(result.sources).containsExactly( + source, + ) + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(source), + ) + } + } + + @Nested + inner class AddSourcesTests { + + @Test + fun `add source`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addSources(listOf( + source, + )) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `cannot add duplicated source link`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.addSources(listOf( + source, + )) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + ) + + // when + anime.addSources(listOf( + source, + )) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class AddSourcesVarargTests { + + @Test + fun `add source`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addSources( + source, + ) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `cannot add duplicated source link`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.addSources(source) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + ) + + // when + anime.addSources(source) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class RemoveSourceIfTests { + + @Test + fun `successfully remove source`() { + // given + val source = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.removeSourceIf { it.toString() == "https://myanimelist.net/anime/2994" } + + // then + assertThat(anime.sources).isEmpty() + } + + @Test + fun `don't remove anything if the condition doesn't match`() { + // given + val source = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.removeSourceIf { it.toString().contains("anidb.net") } + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Nested + inner class AddRelatedAnimeConstructorTests { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://example.org/anime/1535")), + RelatedAnime(URI("https://myanimelist.net/anime/1535")), + ), + ) + + // when + anime.relatedAnime.clear() + + // then + assertThat(anime.relatedAnime).isNotEmpty() + } + + @Test + fun `add related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + + // when + val result = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add duplicated link for related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + + // when + val result = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + RelatedAnime(relatedAnime), + ), + ) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add a related anime if the links is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + _relatedAnime = hashSetOf( + RelatedAnime(link), + ), + ) + + // then + assertThat(result.relatedAnime).isEmpty() + } + + @Test + fun `doesn't remove sources from relatedAnimeif activateChecks is false`() { + // when + val obj = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/6351"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2167")), + RelatedAnime(URI("https://myanimelist.net/anime/6351")), + ), + activateChecks = false, + ) + + // then + assertThat(obj.relatedAnime).hasSize(2) + } + } + + @Nested + inner class AddRelatedAnimeTests { + + @Test + fun `add related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addRelatedAnime(listOf( + relatedAnime, + )) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add duplicated link for related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.addRelatedAnime(listOf( + relatedAnime, + )) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add a related anime if the links is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + ) + + // when + anime.addRelatedAnime(listOf( + link, + )) + + // then + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class AddRelatedAnimeVarargTests { + + @Test + fun `add related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addRelatedAnime( + relatedAnime, + ) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add duplicated link for related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.addRelatedAnime( + relatedAnime, + ) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add a related anime if the links is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + ) + + // when + anime.addRelatedAnime(link) + + // then + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class AddRelatedAnimeWithRelationTests { + + @Test + fun `add related anime including the kind of relation`() { + // given + val relatedAnime = RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addRelatedAnime(relatedAnime) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ) + } + + @Test + fun `an entry having a relation replaces an entry for the same link without relation`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + // when + anime.addRelatedAnime(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ) + } + + @Test + fun `an entry without relation doesn't replace an entry for the same link having a relation`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ), + ) + + // when + anime.addRelatedAnime(URI("https://myanimelist.net/anime/2994")) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ) + } + + @Test + fun `keeps only one entry per link`() { + // when + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Other"), + ), + ) + + // then + assertThat(anime.relatedAnime).hasSize(1) + } + + @Test + fun `cannot add a related anime having a relation if the link is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + ) + + // when + anime.addRelatedAnime(RelatedAnime(link, "Sequel")) + + // then + assertThat(anime.relatedAnime).isEmpty() + } + + @Test + fun `merging keeps the kind of relation`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://myanimelist.net/anime/1535")), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")), + ) + val other = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://anilist.co/anime/2994"), "SUMMARY")), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + RelatedAnime(URI("https://anilist.co/anime/2994"), "SUMMARY"), + ) + } + } + + @Nested + inner class RemoveRelationIfTests { + + @Test + fun `successfully remove related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.removeRelatedAnimeIf { it.toString() == "https://myanimelist.net/anime/2994" } + + // then + assertThat(anime.relatedAnime).isEmpty() + } + + @Test + fun `don't remove anything if condition doesn't match`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.removeRelatedAnimeIf { it.toString().contains("anidb.net") } + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + } + } + + @Nested + inner class EqualityTests { + + @Test + fun `is equal if titles are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + ) + + val b = AnimeRaw( + _title = title, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if titles are different`() { + // given + val a = AnimeRaw( + _title = "Death Note", + ) + + val b = AnimeRaw( + _title = "デスノート", + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if source links are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + ) + + val b = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if source links are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + ) + + val b = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + URI("https://anidb.net/anime/4563"), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if synonyms are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + val b = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if synonyms are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + val b = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + "Quaderno della Morte", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if types are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + type = TV, + ) + + val b = AnimeRaw( + _title = title, + type = TV, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if types are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + type = TV, + ) + + val b = AnimeRaw( + _title = title, + type = MOVIE, + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if episodes are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + episodes = 37, + ) + + val b = AnimeRaw( + _title = title, + episodes = 37, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if episodes are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + episodes = 37, + ) + + val b = AnimeRaw( + _title = title, + episodes = 1, + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if status is the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + status = FINISHED, + ) + + val b = AnimeRaw( + _title = title, + status = FINISHED, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if status is different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + status = FINISHED, + ) + + val b = AnimeRaw( + _title = title, + status = UNKNOWN_STATUS, + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if animeSeasons are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = FALL, + year = 2006, + ), + ) + + val b = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = FALL, + year = 2006, + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if animeSeasons are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = UNDEFINED, + year = 2006, + ), + ) + + val b = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = FALL, + year = 2006, + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if pictures are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + picture = URI("https://example.org/pic/1.png"), + ) + + val b = AnimeRaw( + _title = title, + picture = URI("https://example.org/pic/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if pictures are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + picture = URI("https://example.org/pictures/1.png"), + ) + + val b = AnimeRaw( + _title = title, + picture = URI("https://example.org/pic/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if thumbnail are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnail/1.png"), + ) + + val b = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnail/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if thumbnail are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnails/1.png"), + ) + + val b = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnail/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if durations are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + duration = Duration(20, MINUTES), + ) + + val b = AnimeRaw( + _title = title, + duration = Duration(20, MINUTES), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if durations are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + duration = Duration(21, MINUTES), + ) + + val b = AnimeRaw( + _title = title, + duration = Duration(20, MINUTES), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if scores are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + val b = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if scores are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + val b = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + MetaDataProviderScoreValue( + hostname = "other.com", + value = 9.50, + range = 1.0..10.0, + ) + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if related anime are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + val b = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if related anime are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + val b = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + RelatedAnime(URI("http://anilist.co/anime/2994")), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if tags are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _tags = hashSetOf( + "comedy", + "slice of life", + ), + ) + + val b = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + "comedy", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if tags are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + ), + ) + + val b = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + "comedy", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if studios are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if studios are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if producers are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if producers are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if the other object is of a different type`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + ), + ) + + // when + val result = a.equals(1) + + // then + assertThat(result).isFalse() + } + } + + @Nested + inner class MergeTests { + + @Test + fun `use this number of episodes` () { + // given + val anime = AnimeRaw( + _title = "this", + episodes = 12, + ) + val other = AnimeRaw( + _title = "other", + episodes = 13, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.episodes).isEqualTo(12) + } + + @Test + fun `use other's number of episodes if this number of episodes is 0` () { + // given + val anime = AnimeRaw( + _title = "this", + episodes = 0, + ) + val other = AnimeRaw( + _title = "other", + episodes = 13, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.episodes).isEqualTo(13) + } + + @Test + fun `use this type` () { + // given + val anime = AnimeRaw( + _title = "this", + type = MOVIE, + ) + val other = AnimeRaw( + _title = "other", + type = SPECIAL, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + + @Test + fun `use other's type if this type is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + type = UNKNOWN_TYPE, + ) + val other = AnimeRaw( + _title = "other", + type = SPECIAL, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + + @Test + fun `use this status` () { + // given + val anime = AnimeRaw( + _title = "this", + status = FINISHED, + ) + val other = AnimeRaw( + _title = "other", + status = ONGOING, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + + @Test + fun `use other's status if this status is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + status = UNKNOWN_STATUS, + ) + val other = AnimeRaw( + _title = "other", + status = ONGOING, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + + @Test + fun `use this duration` () { + // given + val anime = AnimeRaw( + _title = "this", + duration = Duration(120, MINUTES), + ) + val other = AnimeRaw( + _title = "other", + duration = Duration(125, MINUTES), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.duration).isEqualTo(Duration(120, MINUTES)) + } + + @Test + fun `use other's duration if this duration is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + duration = UNKNOWN_DURATION, + ) + val other = AnimeRaw( + _title = "other", + duration = Duration(125, MINUTES), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.duration).isEqualTo(Duration(125, MINUTES)) + } + + @Test + fun `use this season` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = FALL, + year = 2010, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + assertThat(result.animeSeason.year).isEqualTo(2010) + } + + @Test + fun `use other's season if this season is UNDEFINED` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = UNDEFINED, + year = 2010, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + assertThat(result.animeSeason.year).isEqualTo(2010) + } + + @Test + fun `use this year` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = FALL, + year = 2010, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + assertThat(result.animeSeason.year).isEqualTo(2010) + } + + @Test + fun `use other's year if this year is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + assertThat(result.animeSeason.year).isEqualTo(2011) + } + + @Test + fun `add title and synonyms of the other anime to this anime's synonyms`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + val other = AnimeRaw( + _title = "DEATH NOTE", + _synonyms = hashSetOf( + "Caderno da Morte", + "Quaderno della Morte", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.title).isEqualTo(anime.title) + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Caderno da Morte", + "DEATH NOTE", + "Quaderno della Morte", + ) + } + + @Test + fun `merge related anime and source links`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.sources).containsExactlyInAnyOrder( + URI("https://anidb.net/anime/4563"), + URI("https://myanimelist.net/anime/1535"), + ) + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ) + } + + @Test + fun `merge tags`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _tags = hashSetOf( + "Psychological", + "Thriller", + "Shounen", + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _tags = hashSetOf( + "Mystery", + "Police", + "Psychological", + "Supernatural", + "Thriller", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "mystery", + "police", + "psychological", + "shounen", + "supernatural", + "thriller", + ) + } + + @Test + fun `merge studios`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _studios = hashSetOf( + "studio 1" + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _studios = hashSetOf( + "studio 2", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "studio 1", + "studio 2", + ) + } + + @Test + fun `merge producers`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _producers = hashSetOf( + "producer 1" + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _producers = hashSetOf( + "producer 2", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "producer 1", + "producer 2", + ) + } + + @Test + fun `merge scores`() { + // given + val title = "Death Note" + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val a = AnimeRaw( + _title = title, + ).addScores( + scoreA, + ) + + val scoreB = MetaDataProviderScoreValue( + hostname = "other.com", + value = 9.5, + range = 1.0..10.0, + ) + val b = AnimeRaw( + _title = title, + ).addScores( + scoreB, + ) + + // when + val result = a.mergeWith(b) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreA, + scoreB, + ) + } + + @Test + fun `overrides score entries with same hostname`() { + // given + val title = "Death Note" + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 2.41, + range = 1.0..5.0, + ) + val a = AnimeRaw( + _title = title, + ).addScores( + scoreA, + ) + + val scoreB = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val b = AnimeRaw( + _title = title, + ).addScores( + scoreB, + ) + + // when + val result = a.mergeWith(b) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreB, + ) + } + } + + @Nested + inner class TagsTest { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _tags = hashSetOf( + "thriller", + "acion", + ), + ) + + // when + anime.tags.clear() + + // then + assertThat(anime.tags).isNotEmpty() + } + + @Nested + inner class AddTagsConstructorTests { + + @Test + fun `tags added by constructor are set to lower case`() { + // given + val tag = "EXAMPLE" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + tag, + ), + ) + + // then + assertThat(result.tags).containsExactly( + tag.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from tags`() { + // given + val expectedTag = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + " $expectedTag", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `remove tailing whitespace from tags`() { + // given + val expectedTag = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "$expectedTag ", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in tags`() { + // given + val expectedTag = "slice of life" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "slice of life", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank tag`(input: String) { + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.tags).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTag = "slice of life" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "slice${input}of${input}life", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTag = "slice of life" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "sli${input}ce of li${input}fe", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `prevent duplicates`() { + // given + val tag1 = "a tag" + val tag2 = "before the other" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + tag2, + tag1, + tag1, + tag2, + ), + ) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + tag1, + tag2, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _tags = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _tags = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) + fun `doesn't fix tags if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + value, + ), + activateChecks = false, + ) + + // then + assertThat(obj.tags).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddTagsTests { + + @Test + fun `tags added are set to lower case`() { + // given + val tag = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + tag, + )) + + // then + assertThat(anime.tags).containsExactly( + tag.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + " $expectedTag", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `remove tailing whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "$expectedTag ", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in tags`() { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "slice of life", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank tag`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "$input $input", + )) + + // then + assertThat(anime.tags).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "slice${input}of${input}life", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "sli${input}ce o${input}f li${input}fe", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `prevent duplicates`() { + // given + val tag1 = "a tag" + val tag2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + tag2, + tag1, + tag1, + tag2, + )) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + tag1, + tag2, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddTagsVarargTests { + + @Test + fun `tags added are set to lower case`() { + // given + val tag = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addTags(tag) + + // then + assertThat(anime.tags).containsExactly( + tag.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + " $expectedTag", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `remove tailing whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "$expectedTag ", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in tags`() { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "slice of life", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank tag`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "$input $input", + ) + + // then + assertThat(anime.tags).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "slice${input}of${input}life", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "sli${input}ce o${input}f li${input}fe", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `prevent duplicates`() { + // given + val tag1 = "a tag" + val tag2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + tag2, + tag1, + tag1, + tag2, + ) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + tag1, + tag2, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `default duration is 0 seconds`() { + // given + val expectedDuration = Duration(0, SECONDS) + + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.duration).isEqualTo(expectedDuration) + } + + @Test + fun `setting a duration of 10 seconds`() { + // when + val result = Duration(10, SECONDS) + + // then + assertThat(result.duration).isEqualTo(10) + assertThat(result.toString()).isEqualTo("10 seconds") + } + + @Test + fun `duration of a minute is equal to a duration of 60 seconds`() { + // given + val durationInSeconds = Duration(60, SECONDS) + + // when + val result = Duration(1, MINUTES) + + // then + assertThat(result).isEqualTo(durationInSeconds) + assertThat(result.duration).isEqualTo(60) + assertThat(result.toString()).isEqualTo("60 seconds") + } + + @Test + fun `duration of an hour is equal to a duration of 60 minutes`() { + // given + val durationInMinutes = Duration(60, MINUTES) + + // when + val result = Duration(1, HOURS) + + // then + assertThat(result).isEqualTo(durationInMinutes) + assertThat(result.duration).isEqualTo(3600) + assertThat(result.toString()).isEqualTo("3600 seconds") + } + + @Test + fun `returns false on equals check if other object is not of the same type`() { + // given + val duration = Duration(60, MINUTES) + + // when + val result = duration.equals(EMPTY) + + // then + assertThat(result).isFalse() + } + } + + @Nested + inner class AnimeSeasonTests { + + @Test + fun `default year is 0 indicating unknown and season is undefined`() { + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.animeSeason.year).isZero() + assertThat(result.animeSeason.isYearOfPremiereUnknown()).isTrue() + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Nested + inner class SeasonTests { + + @Test + fun `default season if nothing has been set`() { + // when + val result = AnimeRaw("test") + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + assertThat(result.animeSeason.year).isZero() + } + } + + @Nested + inner class ToStringTests { + + @Test + fun `create formatted string listing all properties`() { + // when + val result = TestAnimeRawObjects.AllPropertiesSet.obj.toString() + + // then + assertThat(result).isEqualTo( + """ + AnimeRaw( + sources = [https://anilist.co/anime/177191] + title = Go-toubun no Hanayome * + type = SPECIAL + episodes = 2 + status = FINISHED + animeSeason = AnimeSeason(season=FALL, year=2024) + picture = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg + thumbnail = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg + duration = 1440 seconds + scores = [MetaDataProviderScoreValue(hostname=anilist.co, value=75.0, range=1.0..100.0)] + synonyms = [The Quintessential Quintuplets*, 五等分の花嫁*] + studios = [bibury animation studios] + producers = [dax production, nichion, pony canyon] + relatedAnime = [https://anilist.co/anime/131520] + tags = [comedy, drama, ensemble cast, female harem, heterosexual, language barrier, male protagonist, marriage, primarily female cast, romance, school, shounen, twins] + ) + """.trimIndent() + ) + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `default value is 0`() { + // when + val result = AnimeRaw("test") + + // then + assertThat(result.episodes).isZero() + } + + @Test + fun `throws exception if number of episodes is negative`() { + // when + val result = assertThrows<IllegalArgumentException> { + AnimeRaw( + _title = "test", + episodes = -1, + ) + } + + // then + assertThat(result).hasMessage("Episodes cannot have a negative value.") + } + + @Test + fun `doesn't throw an exception if number of episodes is negative and activateChecks is false`() { + // when + val result = AnimeRaw( + _title = "test", + episodes = -1, + activateChecks = false, + ) + + // then + assertThat(result.episodes).isEqualTo(-1) + } + } + + @Nested + inner class PerformChecksTests { + + @Nested + inner class TitleTests { + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) + fun `fixes title`(value: String) { + // given + val obj = AnimeRaw( + _title = value, + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "Death Note", + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + } + + @Nested + inner class EpisodeTests { + + @Test + fun `throws exception if number of episodes is negative`() { + val anime = AnimeRaw( + _title = "test", + episodes = -1, + activateChecks = false, + ) + + // when + val result = assertThrows<IllegalArgumentException> { + anime.performChecks() + } + + // then + assertThat(result).hasMessage("Episodes cannot have a negative value.") + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `removes sources from relatedAnime`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/6351"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2167")), + RelatedAnime(URI("https://myanimelist.net/anime/6351")), + ), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(""" + { + "sources": [ + "https://myanimelist.net/anime/6351" + ], + "title": "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED" + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2167" + } + ], + "tags": [] + } + """.trimIndent()) + } + } + + @Nested + inner class SynonymsTests { + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) + fun `fixes synonyms`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + "Death Note", + ), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", " ", "\u200C"]) + fun `removes blank entries from synonyms`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _synonyms = HashSet(), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + } + + @Nested + inner class TagsTests { + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE"]) + fun `fixes tags`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + "death note", + ), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", " ", "\u200C"]) + fun `removes blank entries from tags`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _tags = HashSet(), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + activateChecks = false, + ) + + // when + anime.performChecks() + + // then + assertThat(anime.sources).containsExactly(source) + assertThat(anime.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `default is empty`() { + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.scores).isEmpty() + } + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + // when + anime.scores.clear() + + // then + assertThat(anime.scores).isNotEmpty() + } + + @Nested + inner class AddTagsTests { + + @Test + fun `ignores NoMetaDataProviderScore`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + val result = anime.addScores( + NoMetaDataProviderScore, + ) + + // then + assertThat(result.scores).isEmpty() + } + + @Test + fun `correctly adds score`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + score, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + + @Test + fun `correctly adds multiple scores`() { + // given + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val scoreB = MetaDataProviderScoreValue( + hostname = "other.com", + value = 6.83, + range = 1.0..10.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + scoreA, + scoreB, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreA, + scoreB, + ) + } + + @Test + fun `overrides existing score if the hostname is identical`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 2.54, + range = 1.0..5.0, + ), + ) + + // when + val result = anime.addScores( + score, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + } + + @Nested + inner class AddTagsVarargTests { + + @Test + fun `ignores NoMetaDataProviderScore`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + val result = anime.addScores( + listOf( + NoMetaDataProviderScore, + ) + ) + + // then + assertThat(result.scores).isEmpty() + } + + @Test + fun `correctly adds score`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + listOf( + score, + ) + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + + @Test + fun `correctly adds multiple scores`() { + // given + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val scoreB = MetaDataProviderScoreValue( + hostname = "other.com", + value = 6.83, + range = 1.0..10.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + listOf( + scoreA, + scoreB, + ) + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreA, + scoreB, + ) + } + + @Test + fun `overrides existing score if the hostname is identical`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ).addScores( + listOf( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 2.54, + range = 1.0..5.0, + ), + ) + ) + + // when + val result = anime.addScores( + score, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + } + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnimeTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnimeTest.kt new file mode 100644 index 00000000..93a33d99 --- /dev/null +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/RelatedAnimeTest.kt @@ -0,0 +1,98 @@ +package io.github.manamiproject.modb.core.anime + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import kotlin.test.Test + +internal class RelatedAnimeTest { + + @Nested + inner class OfTests { + + @Test + fun `keeps the label as the metadata provider writes it`() { + // when + val result = RelatedAnime.of(URI("https://myanimelist.net/anime/2994"), "Side Story") + + // then + assertThat(result).isEqualTo(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Side Story")) + } + + @Test + fun `trims the label and normalizes whitespaces`() { + // when + val result = RelatedAnime.of(URI("https://anidb.net/anime/405"), "\n\t Side story \n") + + // then + assertThat(result.relation).isEqualTo("Side story") + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", "\n", "\t"]) + fun `blank label results in null`(value: String) { + // when + val result = RelatedAnime.of(URI("https://anidb.net/anime/405"), value) + + // then + assertThat(result.relation).isNull() + } + + @Test + fun `null label results in null`() { + // when + val result = RelatedAnime.of(URI("https://anidb.net/anime/405"), null) + + // then + assertThat(result.relation).isNull() + } + } + + @Nested + inner class CompareToTests { + + @Test + fun `sorts by link first and by relation second`() { + // given + val list = listOf( + RelatedAnime(URI("https://myanimelist.net/anime/2"), "Sequel"), + RelatedAnime(URI("https://myanimelist.net/anime/1"), "Sequel"), + RelatedAnime(URI("https://myanimelist.net/anime/1")), + ) + + // when + val result = list.sorted() + + // then + assertThat(result).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/1")), + RelatedAnime(URI("https://myanimelist.net/anime/1"), "Sequel"), + RelatedAnime(URI("https://myanimelist.net/anime/2"), "Sequel"), + ) + } + } + + @Nested + inner class ToStringTests { + + @Test + fun `only the link if there is no relation`() { + // when + val result = RelatedAnime(URI("https://myanimelist.net/anime/2994")).toString() + + // then + assertThat(result).isEqualTo("https://myanimelist.net/anime/2994") + } + + @Test + fun `link and relation in brackets`() { + // when + val result = RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary").toString() + + // then + assertThat(result).isEqualTo("https://myanimelist.net/anime/2994 (Summary)") + } + } +} diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt index 2c8eab3f..51747534 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt @@ -1,508 +1,508 @@ -package io.github.manamiproject.modb.core.extractor - -import io.github.manamiproject.modb.core.extensions.EMPTY -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource - -internal class DescendantTest { - - @Nested - inner class TerminatingChildTests { - - @ParameterizedTest - @ValueSource(strings = ["td", "//td", "/td"]) - fun `element without terminating child`(input: String) { - // given - val obj = Descendant(input) - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isFalse() - assertThat(isTerminatingChild).isFalse() - assertThat(terminatingChild).isEqualTo(EMPTY) - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()"]) - fun `only contains terminating child`(input: String) { - // given - val obj = Descendant(input) - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isFalse() - assertThat(isTerminatingChild).isTrue() - assertThat(terminatingChild).isEmpty() - assertThat(obj.toJsoup()).isEqualTo(input) - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()"]) - fun `single descendant followed by terminating child`(input: String) { - // given - val obj = Descendant("//div/$input") - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isTrue() - assertThat(isTerminatingChild).isFalse() - assertThat(terminatingChild).isEqualTo(input) - assertThat(obj.toJsoup()).isEqualTo("div") - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()"]) - fun `contains terminating child after additional child and filter`(input: String) { - // given - val obj = Descendant("//div/span[@attr='test']/$input") - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isTrue() - assertThat(isTerminatingChild).isFalse() - assertThat(terminatingChild).isEqualTo(input) - assertThat(obj.toJsoup()).isEqualTo("div > span[attr=test]") - } - } - - @Nested - inner class ToJsoupTests { - - @ParameterizedTest - @ValueSource(strings = ["td", "//td", "/td"]) - fun `return element name`(input: String) { - // given - val obj = Descendant(input) - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("td") - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()"]) - fun `empty if only contains terminating child`(input: String) { - // given - val obj = Descendant(input) - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo(input) - } - - @Nested - inner class FilterElementByAttributeEqualsValue { - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value`(input: String) { - // given - val obj = Descendant("//div[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["@class=", "@class= ", "@class =", "@class = "]) - fun `filter element by attribute equals value works with whitespaces`(input: String) { - // given - val obj = Descendant("//div[$input'test']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=test]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value followed by terminating child`(input: String) { - // given - val obj = Descendant("//div[@class='$input']/@itemprop") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value followed by child`(input: String) { - // given - val obj = Descendant("//div[@class='$input']/span") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=$input] > span") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value on child`(input: String) { - // given - val obj = Descendant("//div/span[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value multiple appearances`(input: String) { - // given - val obj = Descendant("//div[@itemprop='test']/span[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop=test] > span[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value multiple appearances on same element`(input: String) { - // given - val obj = Descendant("//div/span[@itemprop='test'][@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[itemprop=test][class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value combined with different filter`(input: String) { - // given - val obj = Descendant("//div[contains(@itemprop, 'test')]/span[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop*=test] > span[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value combined with different filter on same element`(input: String) { - // given - val obj = Descendant("//div/span[@class='$input'][contains(@itemprop, 'test')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class=$input][itemprop*=test]") - } - } - - @Nested - inner class FilterElementByAttributeContains { - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value`(input: String) { - // given - val obj = Descendant("//div[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["@class,", "@class, ", "@class ,", "@class , "]) - fun `filter element by attribute contains value works with whitespaces`(input: String) { - // given - val obj = Descendant("//div[contains($input'test')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=test]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value followed by terminating child`(input: String) { - // given - val obj = Descendant("//div[contains(@class, '$input')]/@itemprop") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value followed by child`(input: String) { - // given - val obj = Descendant("//div[contains(@class, '$input')]/span") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=$input] > span") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value on child`(input: String) { - // given - val obj = Descendant("//div/span[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value multiple appearances`(input: String) { - // given - val obj = Descendant("//div[contains(@itemprop, 'test')]/span[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop*=test] > span[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value multiple appearances on same element`(input: String) { - // given - val obj = Descendant("//div/span[contains(@itemprop, 'test')][contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[itemprop*=test][class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value combined with different filter`(input: String) { - // given - val obj = Descendant("//div[@itemprop='test']/span[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop=test] > span[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value combined with different filter on same element`(input: String) { - // given - val obj = Descendant("//div/span[contains(@class, '$input')][@itemprop='test']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class*=$input][itemprop=test]") - } - } - - @Nested - inner class FilterElementByTextContains { - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value`(input: String) { - // given - val obj = Descendant("//div[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["text(),", "text(), ", "text() ,", "text() , "]) - fun `filter element by text contains value works with whitespaces`(input: String) { - // given - val obj = Descendant("//div[contains($input'test')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn(test)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value followed by terminating child`(input: String) { - // given - val obj = Descendant("//div[contains(text(), '$input')]/@itemprop") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value followed by child`(input: String) { - // given - val obj = Descendant("//div[contains(text(), '$input')]/span") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn($input) > span") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value on child`(input: String) { - // given - val obj = Descendant("//div/span[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value multiple appearances`(input: String) { - // given - val obj = Descendant("//div[contains(text(), 'test')]/span[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn(test) > span:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value combined with different filter`(input: String) { - // given - val obj = Descendant("//div[@itemprop='test']/span[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop=test] > span:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value combined with different filter on same element`(input: String) { - // given - val obj = Descendant("//div/span[contains(text(), '$input')][@itemprop='test']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span:matchesOwn($input)[itemprop=test]") - } - } - - @Nested - inner class FollowingSiblingTests { - - @ParameterizedTest - @ValueSource(strings = ["*", "td"]) - fun `select follow-sibling`(input: String) { - // given - val obj = Descendant("//div/following-sibling::$input") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div ~ $input") - } - - @ParameterizedTest - @ValueSource(strings = ["*", "td"]) - fun `select follow-sibling select text`(input: String) { - // given - val obj = Descendant("//div/following-sibling::$input/text()") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div ~ $input") - } - - @ParameterizedTest - @ValueSource(strings = ["*", "td"]) - fun `select follow-sibling in between`(input: String) { - // given - val obj = Descendant("//div/span/following-sibling::$input/a/@href") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span ~ $input > a") - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.extractor + +import io.github.manamiproject.modb.core.extensions.EMPTY +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +internal class DescendantTest { + + @Nested + inner class TerminatingChildTests { + + @ParameterizedTest + @ValueSource(strings = ["td", "//td", "/td"]) + fun `element without terminating child`(input: String) { + // given + val obj = Descendant(input) + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isFalse() + assertThat(isTerminatingChild).isFalse() + assertThat(terminatingChild).isEqualTo(EMPTY) + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `only contains terminating child`(input: String) { + // given + val obj = Descendant(input) + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isFalse() + assertThat(isTerminatingChild).isTrue() + assertThat(terminatingChild).isEmpty() + assertThat(obj.toJsoup()).isEqualTo(input) + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `single descendant followed by terminating child`(input: String) { + // given + val obj = Descendant("//div/$input") + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isTrue() + assertThat(isTerminatingChild).isFalse() + assertThat(terminatingChild).isEqualTo(input) + assertThat(obj.toJsoup()).isEqualTo("div") + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `contains terminating child after additional child and filter`(input: String) { + // given + val obj = Descendant("//div/span[@attr='test']/$input") + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isTrue() + assertThat(isTerminatingChild).isFalse() + assertThat(terminatingChild).isEqualTo(input) + assertThat(obj.toJsoup()).isEqualTo("div > span[attr=test]") + } + } + + @Nested + inner class ToJsoupTests { + + @ParameterizedTest + @ValueSource(strings = ["td", "//td", "/td"]) + fun `return element name`(input: String) { + // given + val obj = Descendant(input) + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("td") + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `empty if only contains terminating child`(input: String) { + // given + val obj = Descendant(input) + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo(input) + } + + @Nested + inner class FilterElementByAttributeEqualsValue { + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value`(input: String) { + // given + val obj = Descendant("//div[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["@class=", "@class= ", "@class =", "@class = "]) + fun `filter element by attribute equals value works with whitespaces`(input: String) { + // given + val obj = Descendant("//div[$input'test']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=test]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value followed by terminating child`(input: String) { + // given + val obj = Descendant("//div[@class='$input']/@itemprop") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value followed by child`(input: String) { + // given + val obj = Descendant("//div[@class='$input']/span") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=$input] > span") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value on child`(input: String) { + // given + val obj = Descendant("//div/span[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value multiple appearances`(input: String) { + // given + val obj = Descendant("//div[@itemprop='test']/span[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop=test] > span[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value multiple appearances on same element`(input: String) { + // given + val obj = Descendant("//div/span[@itemprop='test'][@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[itemprop=test][class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value combined with different filter`(input: String) { + // given + val obj = Descendant("//div[contains(@itemprop, 'test')]/span[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop*=test] > span[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value combined with different filter on same element`(input: String) { + // given + val obj = Descendant("//div/span[@class='$input'][contains(@itemprop, 'test')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class=$input][itemprop*=test]") + } + } + + @Nested + inner class FilterElementByAttributeContains { + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value`(input: String) { + // given + val obj = Descendant("//div[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["@class,", "@class, ", "@class ,", "@class , "]) + fun `filter element by attribute contains value works with whitespaces`(input: String) { + // given + val obj = Descendant("//div[contains($input'test')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=test]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value followed by terminating child`(input: String) { + // given + val obj = Descendant("//div[contains(@class, '$input')]/@itemprop") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value followed by child`(input: String) { + // given + val obj = Descendant("//div[contains(@class, '$input')]/span") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=$input] > span") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value on child`(input: String) { + // given + val obj = Descendant("//div/span[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value multiple appearances`(input: String) { + // given + val obj = Descendant("//div[contains(@itemprop, 'test')]/span[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop*=test] > span[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value multiple appearances on same element`(input: String) { + // given + val obj = Descendant("//div/span[contains(@itemprop, 'test')][contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[itemprop*=test][class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value combined with different filter`(input: String) { + // given + val obj = Descendant("//div[@itemprop='test']/span[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop=test] > span[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value combined with different filter on same element`(input: String) { + // given + val obj = Descendant("//div/span[contains(@class, '$input')][@itemprop='test']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class*=$input][itemprop=test]") + } + } + + @Nested + inner class FilterElementByTextContains { + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value`(input: String) { + // given + val obj = Descendant("//div[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["text(),", "text(), ", "text() ,", "text() , "]) + fun `filter element by text contains value works with whitespaces`(input: String) { + // given + val obj = Descendant("//div[contains($input'test')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn(test)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value followed by terminating child`(input: String) { + // given + val obj = Descendant("//div[contains(text(), '$input')]/@itemprop") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value followed by child`(input: String) { + // given + val obj = Descendant("//div[contains(text(), '$input')]/span") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn($input) > span") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value on child`(input: String) { + // given + val obj = Descendant("//div/span[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value multiple appearances`(input: String) { + // given + val obj = Descendant("//div[contains(text(), 'test')]/span[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn(test) > span:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value combined with different filter`(input: String) { + // given + val obj = Descendant("//div[@itemprop='test']/span[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop=test] > span:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value combined with different filter on same element`(input: String) { + // given + val obj = Descendant("//div/span[contains(text(), '$input')][@itemprop='test']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span:matchesOwn($input)[itemprop=test]") + } + } + + @Nested + inner class FollowingSiblingTests { + + @ParameterizedTest + @ValueSource(strings = ["*", "td"]) + fun `select follow-sibling`(input: String) { + // given + val obj = Descendant("//div/following-sibling::$input") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div ~ $input") + } + + @ParameterizedTest + @ValueSource(strings = ["*", "td"]) + fun `select follow-sibling select text`(input: String) { + // given + val obj = Descendant("//div/following-sibling::$input/text()") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div ~ $input") + } + + @ParameterizedTest + @ValueSource(strings = ["*", "td"]) + fun `select follow-sibling in between`(input: String) { + // given + val obj = Descendant("//div/span/following-sibling::$input/a/@href") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span ~ $input > a") + } + } + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinksTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinksTest.kt new file mode 100644 index 00000000..d0c44f3e --- /dev/null +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/LabelledLinksTest.kt @@ -0,0 +1,133 @@ +package io.github.manamiproject.modb.core.extractor + +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import kotlin.test.Test + +internal class LabelledLinksTest { + + @Nested + inner class HtmlTerminatingChildTests { + + private val html = """ + <html><body> + <div class="entry"><a href="/anime/1">One</a><span class="type">Sequel</span></div> + <div class="entry"><a href="/anime/2">Two</a></div> + </body></html> + """.trimIndent() + + @Test + fun `html() returns the html of each element using the css selector based extractor`() { + runTest { + // when + val result = JsoupCssSelectorDataExtractor.extract(html, mapOf( + "entries" to "//div[@class='entry']/html()", + )) + + // then + val entries = result.listNotNull<String>("entries") + assertThat(entries).hasSize(2) + assertThat(entries.first()).contains("href=\"/anime/1\"", "Sequel") + assertThat(entries.last()).contains("href=\"/anime/2\"").doesNotContain("Sequel") + } + } + + @Test + fun `html() returns the html of each element using the xpath based extractor`() { + runTest { + // when + val result = JsoupXPathDataExtractor.extract(html, mapOf( + "entries" to "//div[@class='entry']/html()", + )) + + // then + val entries = result.listNotNull<String>("entries") + assertThat(entries).hasSize(2) + assertThat(entries.first()).contains("href=\"/anime/1\"", "Sequel") + assertThat(entries.last()).contains("href=\"/anime/2\"").doesNotContain("Sequel") + } + } + } + + @Nested + inner class ExtractLabelledLinksTests { + + @Test + fun `extracts label and links per snippet`() { + runTest { + // given + val snippets = listOf( + """<div><a href="/anime/1">One</a><a href="/anime/2">Two</a><span class="type">Side Story</span></div>""", + """<div><a href="/anime/3">Three</a><span class="type">Sequel</span></div>""", + ) + + // when + val result = XmlDataExtractor.extractLabelledLinks( + snippets = snippets, + labelSelector = "//span[@class='type']/text()", + linkSelector = "//a/@href", + ) + + // then + assertThat(result).containsExactly( + LabelledLinks("Side Story", listOf("/anime/1", "/anime/2")), + LabelledLinks("Sequel", listOf("/anime/3")), + ) + } + } + + @Test + fun `label is null if the snippet doesn't contain one`() { + runTest { + // when + val result = XmlDataExtractor.extractLabelledLinks( + snippets = listOf("""<div><a href="/anime/1">One</a></div>"""), + labelSelector = "//span[@class='type']/text()", + linkSelector = "//a/@href", + ) + + // then + assertThat(result).containsExactly( + LabelledLinks(null, listOf("/anime/1")), + ) + } + } + + @Test + fun `snippets without links are skipped`() { + runTest { + // when + val result = XmlDataExtractor.extractLabelledLinks( + snippets = listOf("""<div><span class="type">Sequel</span></div>"""), + labelSelector = "//span[@class='type']/text()", + linkSelector = "//a/@href", + ) + + // then + assertThat(result).isEmpty() + } + } + + @Test + fun `table rows are parsed correctly if they are wrapped`() { + runTest { + // given + val row = """<tr><td class="label">Side Story</td><td><a href="/anime/1">One</a></td></tr>""" + + // when + val result = XmlDataExtractor.extractLabelledLinks( + snippets = listOf(row), + labelSelector = "//td[@class='label']/text()", + linkSelector = "//a/@href", + wrap = WRAP_TABLE_ROW, + ) + + // then + assertThat(result).containsExactly( + LabelledLinks("Side Story", listOf("/anime/1")), + ) + } + } + } +} diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt index 89abd361..799f7667 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt @@ -1,1798 +1,1799 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.JsonDataException -import io.github.manamiproject.modb.core.TestAnimeRawObjects -import io.github.manamiproject.modb.test.exceptionExpected -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeRawAdapterTest { - - @Nested - inner class FromJsonTests { - - @Test - fun `correctly deserialize non-null value`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) - } - - @Test - fun `correctly deserialize nullable values`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.obj) - } - - @Test - fun `correctly deserialize object with default values`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) - } - - @Test - fun `throw exception on null value`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson("""null""") - } - - // then - assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$") - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `throws exception if title is blank`(value: String) { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalArgumentException> { - adapter.fromJson(""" - { - "sources": [], - "title": "$value", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Title cannot be blank.") - } - - @Test - fun `throws exception if title is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": null, - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.title") - } - - @Test - fun `throws exception if title is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'title' is either missing or null.") - } - - @Test - fun `throws exception if sources is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": null, - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.sources") - } - - @Test - fun `throws exception if sources is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'sources' is either missing or null.") - } - - @Test - fun `throws exception if type is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": null, - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.type") - } - - @Test - fun `throws exception if type is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'type' is either missing or null.") - } - - @Test - fun `throws exception if episodes is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": null, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected an int but was NULL at path \$.episodes") - } - - @Test - fun `throws exception if episodes is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'episodes' is either missing or null.") - } - - @Test - fun `throws exception if status is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": null, - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.status") - } - - @Test - fun `throws exception if status is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'status' is either missing or null.") - } - - @Test - fun `throws exception if animeSeason is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": null, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$.animeSeason") - } - - @Test - fun `throws exception if animeSeason is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'animeSeason' is either missing or null.") - } - - @Test - fun `throws exception if season in animeSeason is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": null, - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.animeSeason.season") - } - - @Test - fun `throws exception if season in animeSeason is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'season' is either missing or null.") - } - - @Test - fun `throws exception if picture is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": null, - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.picture") - } - - @Test - fun `throws exception if picture is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'picture' is either missing or null.") - } - - @Test - fun `throws exception if thumbnail is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": null, - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.thumbnail") - } - - @Test - fun `throws exception if thumbnail is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'thumbnail' is either missing or null.") - } - - @Test - fun `throws exception if value in duration is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": null, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected an int but was NULL at path \$.duration.value") - } - - @Test - fun `throws exception if value in duration is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'value' is either missing or null.") - } - - @Test - fun `throws exception if unit in duration is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": null - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.duration.unit") - } - - @Test - fun `throws exception if unit in duration is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380 - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'unit' is either missing or null.") - } - - @Test - fun `throws exception if synonyms is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": null, - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.synonyms") - } - - @Test - fun `throws exception if synonyms is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'synonyms' is either missing or null.") - } - - @Test - fun `throws exception if studios is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": null, - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.studios") - } - - @Test - fun `throws exception if studios is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'studios' is either missing or null.") - } - - @Test - fun `throws exception if producers is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": null, - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.producers") - } - - @Test - fun `throws exception if producers is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'producers' is either missing or null.") - } - - @Test - fun `throws exception if relatedAnime is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": null, - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.relatedAnime") - } - - @Test - fun `throws exception if relatedAnime is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'relatedAnime' is either missing or null.") - } - - @Test - fun `throws exception if tags is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": null - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.tags") - } - - @Test - fun `throws exception if tags is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'tags' is either missing or null.") - } - - @Test - fun `returns anime with year 0 if year in animeSeason is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.animeSeason.year).isZero() - } - - @Test - fun `returns anime with year 0 if year in animeSeason is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED" - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.animeSeason.year).isZero() - } - - @Test - fun `returns anime with unknown duration if duration property is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) - } - - @Test - fun `returns anime with unknown duration if duration property is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) - } - } - - @Nested - inner class ToJsonTests { - - @Test - fun `correctly serialize non-null value`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `correctly serialize nullable data if serializeNulls has been set`() { - // given - val adapter = AnimeRawAdapter().indent(" ").serializeNulls() - val obj = TestAnimeRawObjects.NullableNotSet.obj - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) - } - - @Test - fun `correctly serialize object with default values`() { - // given - val adapter = AnimeRawAdapter().indent(" ").serializeNulls() - val obj = TestAnimeRawObjects.DefaultAnime.obj - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and fixes title`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _title = " ${value}Go-toubun$value no$value$value Hanayome *$value$value ", - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `runs performChecks if activateChecks is false and throws an exception if episodes is negative`() { - // given - val adapter = AnimeRawAdapter() - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - episodes = -1, - activateChecks = false, - ) - - // when - val result = exceptionExpected<IllegalArgumentException> { - adapter.toJson(obj) - } - - // then - assertThat(result).hasMessage("Episodes cannot have a negative value.") - } - - @Test - fun `runs performChecks if activateChecks is false and removes sources from relatedAnime`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.union(TestAnimeRawObjects.AllPropertiesSet.obj.sources).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and fixes synonyms`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union( - hashSetOf(" ${value}The $value${value}Quintessential $value$value${value}Quintuplets*$value$value ") - ).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and removes blank entries from synonyms`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union(hashSetOf(value)).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [" romance", "romance ", "ROMANCE"]) - fun `runs performChecks if activateChecks is false and fixes tags`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and removes blank entries from tags`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `sources, synonyms, relatedAnime and tags are being sorted asc by their string representation`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _sources = TestAnimeRawObjects.AllPropertiesSet.obj.sources.toList().shuffled().toHashSet(), - _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.toList().shuffled().toHashSet(), - _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.toList().shuffled().toHashSet(), - _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.toList().shuffled().toHashSet(), - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `sort scores by hostname`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.copy().addScores( - TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.scores.shuffled(), - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.FullyMergedAllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `throws exception for a null value`() { - // given - val adapter = AnimeRawAdapter().serializeNulls() - - // when - val result = exceptionExpected<IllegalArgumentException> { - adapter.toJson(null) - } - - // then - assertThat(result).hasMessage("AnimeRawAdapter expects non-nullable value, but received null.") - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import com.squareup.moshi.JsonDataException +import io.github.manamiproject.modb.core.TestAnimeRawObjects +import io.github.manamiproject.modb.test.exceptionExpected +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeRawAdapterTest { + + @Nested + inner class FromJsonTests { + + @Test + fun `correctly deserialize non-null value`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) + } + + @Test + fun `correctly deserialize nullable values`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.obj) + } + + @Test + fun `correctly deserialize object with default values`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) + } + + @Test + fun `throw exception on null value`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson("""null""") + } + + // then + assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$") + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `throws exception if title is blank`(value: String) { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalArgumentException> { + adapter.fromJson(""" + { + "sources": [], + "title": "$value", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Title cannot be blank.") + } + + @Test + fun `throws exception if title is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": null, + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.title") + } + + @Test + fun `throws exception if title is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'title' is either missing or null.") + } + + @Test + fun `throws exception if sources is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": null, + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.sources") + } + + @Test + fun `throws exception if sources is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'sources' is either missing or null.") + } + + @Test + fun `throws exception if type is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": null, + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.type") + } + + @Test + fun `throws exception if type is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'type' is either missing or null.") + } + + @Test + fun `throws exception if episodes is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": null, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected an int but was NULL at path \$.episodes") + } + + @Test + fun `throws exception if episodes is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'episodes' is either missing or null.") + } + + @Test + fun `throws exception if status is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": null, + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.status") + } + + @Test + fun `throws exception if status is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'status' is either missing or null.") + } + + @Test + fun `throws exception if animeSeason is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": null, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$.animeSeason") + } + + @Test + fun `throws exception if animeSeason is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'animeSeason' is either missing or null.") + } + + @Test + fun `throws exception if season in animeSeason is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": null, + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.animeSeason.season") + } + + @Test + fun `throws exception if season in animeSeason is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'season' is either missing or null.") + } + + @Test + fun `throws exception if picture is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": null, + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.picture") + } + + @Test + fun `throws exception if picture is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'picture' is either missing or null.") + } + + @Test + fun `throws exception if thumbnail is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": null, + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.thumbnail") + } + + @Test + fun `throws exception if thumbnail is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'thumbnail' is either missing or null.") + } + + @Test + fun `throws exception if value in duration is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": null, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected an int but was NULL at path \$.duration.value") + } + + @Test + fun `throws exception if value in duration is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'value' is either missing or null.") + } + + @Test + fun `throws exception if unit in duration is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": null + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.duration.unit") + } + + @Test + fun `throws exception if unit in duration is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380 + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'unit' is either missing or null.") + } + + @Test + fun `throws exception if synonyms is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": null, + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.synonyms") + } + + @Test + fun `throws exception if synonyms is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'synonyms' is either missing or null.") + } + + @Test + fun `throws exception if studios is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": null, + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.studios") + } + + @Test + fun `throws exception if studios is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'studios' is either missing or null.") + } + + @Test + fun `throws exception if producers is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": null, + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.producers") + } + + @Test + fun `throws exception if producers is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'producers' is either missing or null.") + } + + @Test + fun `throws exception if relatedAnime is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": null, + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.relatedAnime") + } + + @Test + fun `throws exception if relatedAnime is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'relatedAnime' is either missing or null.") + } + + @Test + fun `throws exception if tags is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": null + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.tags") + } + + @Test + fun `throws exception if tags is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'tags' is either missing or null.") + } + + @Test + fun `returns anime with year 0 if year in animeSeason is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.animeSeason.year).isZero() + } + + @Test + fun `returns anime with year 0 if year in animeSeason is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED" + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.animeSeason.year).isZero() + } + + @Test + fun `returns anime with unknown duration if duration property is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) + } + + @Test + fun `returns anime with unknown duration if duration property is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) + } + } + + @Nested + inner class ToJsonTests { + + @Test + fun `correctly serialize non-null value`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `correctly serialize nullable data if serializeNulls has been set`() { + // given + val adapter = AnimeRawAdapter().indent(" ").serializeNulls() + val obj = TestAnimeRawObjects.NullableNotSet.obj + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) + } + + @Test + fun `correctly serialize object with default values`() { + // given + val adapter = AnimeRawAdapter().indent(" ").serializeNulls() + val obj = TestAnimeRawObjects.DefaultAnime.obj + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and fixes title`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _title = " ${value}Go-toubun$value no$value$value Hanayome *$value$value ", + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `runs performChecks if activateChecks is false and throws an exception if episodes is negative`() { + // given + val adapter = AnimeRawAdapter() + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + episodes = -1, + activateChecks = false, + ) + + // when + val result = exceptionExpected<IllegalArgumentException> { + adapter.toJson(obj) + } + + // then + assertThat(result).hasMessage("Episodes cannot have a negative value.") + } + + @Test + fun `runs performChecks if activateChecks is false and removes sources from relatedAnime`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.union(TestAnimeRawObjects.AllPropertiesSet.obj.sources.map { RelatedAnime(it) }).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and fixes synonyms`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union( + hashSetOf(" ${value}The $value${value}Quintessential $value$value${value}Quintuplets*$value$value ") + ).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and removes blank entries from synonyms`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union(hashSetOf(value)).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [" romance", "romance ", "ROMANCE"]) + fun `runs performChecks if activateChecks is false and fixes tags`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and removes blank entries from tags`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `sources, synonyms, relatedAnime and tags are being sorted asc by their string representation`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _sources = TestAnimeRawObjects.AllPropertiesSet.obj.sources.toList().shuffled().toHashSet(), + _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.toList().shuffled().toHashSet(), + _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.toList().shuffled().toHashSet(), + _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.toList().shuffled().toHashSet(), + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `sort scores by hostname`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.copy().addScores( + TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.scores.shuffled(), + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.FullyMergedAllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `throws exception for a null value`() { + // given + val adapter = AnimeRawAdapter().serializeNulls() + + // when + val result = exceptionExpected<IllegalArgumentException> { + adapter.toJson(null) + } + + // then + assertThat(result).hasMessage("AnimeRawAdapter expects non-nullable value, but received null.") + } + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt index 2eaf8a8b..a461e7a7 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt @@ -1,437 +1,441 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.JsonDataException -import io.github.manamiproject.modb.core.TestAnimeObjects -import io.github.manamiproject.modb.core.TestAnimeRawObjects -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.util.* -import kotlin.io.path.inputStream -import kotlin.test.Test - -internal class JsonKtTest { - - @Nested - inner class DeserializationTests { - - @Nested - inner class AnimeRawTests { - - @Test - fun `deserialize AnimeRaw - using InputStream, all properties set`() { - tempDirectory { - // given - val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") - TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) - - val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) - - // when - val result = Json.parseJson<AnimeRaw>(inputStream) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize AnimeRaw - using string, all properties set`() { - runTest { - // given - val json = TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint - - // when - val result = Json.parseJson<AnimeRaw>(json) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize AnimeRaw - default properties`() { - runTest { - // given - val json = TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint - - // when - val result = Json.parseJson<AnimeRaw>(json) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) - } - } - } - - @Nested - inner class AnimeTests { - - @Test - fun `deserialize Anime - using inputstream, all properties set`() { - tempDirectory { - // given - val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") - TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) - - val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) - - // when - val result = Json.parseJson<Anime>(inputStream) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize Anime - using string, all properties set`() { - runTest { - // given - val json = TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint - - // when - val result = Json.parseJson<Anime>(json) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize Anime - default properties`() { - runTest { - // given - val json = TestAnimeObjects.DefaultAnime.serializedPrettyPrint - - // when - val result = Json.parseJson<Anime>(json) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.obj) - } - } - } - - @Test - fun `deserialize object - throws exception if a property having a non-nullable type is mapped to null`() { - // given - val json = """ - { - "nullableString": null, - "nonNullableString": null - } - """.trimIndent() - - // when - val result = exceptionExpected<JsonDataException> { - Json.parseJson<NullableTestClass>(json)?.copy() - } - - // then - assertThat(result).hasMessage("Non-null value 'nonNullableString' was null at \$.nonNullableString") - } - - @Test - fun `deserialize an array - non nullable types with default value can contain null`() { - runTest { - // given - val json = """ - { - "nonNullableList": [ - "value1", - null, - "value3" - ] - } - """.trimIndent() - - // when - val result = Json.parseJson<ClassWithList>(json) - - // then - assertThat(result?.nonNullableList).containsNull() - } - } - - @Test - fun `deserialize an array - Although the type of the list is non-nullable and copy is called on a list containing null, no exception is being thrown`() { - runTest { - // given - val json = """ - { - "nonNullableList": [ - "value1", - null, - "value3" - ] - } - """.trimIndent() - - // when - val result = Json.parseJson<ClassWithList>(json)?.copy()!! - - // then - assertThat(result.nonNullableList).containsNull() - } - } - } - - @Nested - inner class SerializationTests { - - @Nested - inner class DefaultOptionsTests { - - @Nested - inner class AnimeRawTests { - - @Test - fun `serialize AnimeRaw - all properties set`() { - runTest { - // given - val anime = TestAnimeRawObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - } - - @Test - fun `serialize AnimeRaw - default properties`() { - runTest { - // given - val anime = TestAnimeRawObjects.DefaultAnime.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) - } - } - } - - @Nested - inner class AnimeTests { - - @Test - fun `serialize Anime - all properties set`() { - runTest { - // given - val anime = TestAnimeObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint) - } - } - - @Test - fun `serialize Anime - default properties`() { - runTest { - // given - val anime = TestAnimeObjects.DefaultAnime.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.serializedPrettyPrint) - } - } - } - - @Test - fun `serialize - option serialize null is activated by default`() { - runTest { - // given - val expectedJson = """ - { - "nullableString": null, - "nonNullableString": "test" - } - """.trimIndent() - - // when - val result = Json.toJson(NullableTestClass()) - - // then - assertThat(result).isEqualTo(expectedJson) - } - } - } - - @Nested - inner class CustomOptionsTests { - - @Nested - inner class AnimeRawTests { - - @Test - fun `serialize AnimeRaw - deactivate pretty print`() { - runTest { - // given - val anime = TestAnimeRawObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedMinified) - } - } - - @Test - fun `serialize AnimeRaw - deactivate serialize null`() { - runTest { - // given - val anime = TestAnimeRawObjects.NullableNotSet.obj - - val expectedJson = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL" - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - // when - val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) - - // then - assertThat(result).isEqualTo(expectedJson) - } - } - } - - @Nested - inner class AnimeTests { - - @Test - fun `serialize Anime - deactivate pretty print`() { - runTest { - // given - val anime = TestAnimeObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedMinified) - } - } - - @Test - fun `serialize Anime - deactivate serialize null`() { - runTest { - // given - val anime = TestAnimeObjects.NullableNotSet.obj - - val expectedJson = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL" - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - // when - val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) - - // then - assertThat(result).isEqualTo(expectedJson) - } - } - } - } - } -} - -private data class NullableTestClass(val nullableString: String? = null, val nonNullableString: String = "test") -private data class ClassWithList(val nonNullableList: List<String> = emptyList()) \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.JsonDataException +import io.github.manamiproject.modb.core.TestAnimeObjects +import io.github.manamiproject.modb.core.TestAnimeRawObjects +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.util.* +import kotlin.io.path.inputStream +import kotlin.test.Test + +internal class JsonKtTest { + + @Nested + inner class DeserializationTests { + + @Nested + inner class AnimeRawTests { + + @Test + fun `deserialize AnimeRaw - using InputStream, all properties set`() { + tempDirectory { + // given + val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") + TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) + + val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) + + // when + val result = Json.parseJson<AnimeRaw>(inputStream) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize AnimeRaw - using string, all properties set`() { + runTest { + // given + val json = TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint + + // when + val result = Json.parseJson<AnimeRaw>(json) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize AnimeRaw - default properties`() { + runTest { + // given + val json = TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint + + // when + val result = Json.parseJson<AnimeRaw>(json) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) + } + } + } + + @Nested + inner class AnimeTests { + + @Test + fun `deserialize Anime - using inputstream, all properties set`() { + tempDirectory { + // given + val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") + TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) + + val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) + + // when + val result = Json.parseJson<Anime>(inputStream) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize Anime - using string, all properties set`() { + runTest { + // given + val json = TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint + + // when + val result = Json.parseJson<Anime>(json) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize Anime - default properties`() { + runTest { + // given + val json = TestAnimeObjects.DefaultAnime.serializedPrettyPrint + + // when + val result = Json.parseJson<Anime>(json) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.obj) + } + } + } + + @Test + fun `deserialize object - throws exception if a property having a non-nullable type is mapped to null`() { + // given + val json = """ + { + "nullableString": null, + "nonNullableString": null + } + """.trimIndent() + + // when + val result = exceptionExpected<JsonDataException> { + Json.parseJson<NullableTestClass>(json)?.copy() + } + + // then + assertThat(result).hasMessage("Non-null value 'nonNullableString' was null at \$.nonNullableString") + } + + @Test + fun `deserialize an array - non nullable types with default value can contain null`() { + runTest { + // given + val json = """ + { + "nonNullableList": [ + "value1", + null, + "value3" + ] + } + """.trimIndent() + + // when + val result = Json.parseJson<ClassWithList>(json) + + // then + assertThat(result?.nonNullableList).containsNull() + } + } + + @Test + fun `deserialize an array - Although the type of the list is non-nullable and copy is called on a list containing null, no exception is being thrown`() { + runTest { + // given + val json = """ + { + "nonNullableList": [ + "value1", + null, + "value3" + ] + } + """.trimIndent() + + // when + val result = Json.parseJson<ClassWithList>(json)?.copy()!! + + // then + assertThat(result.nonNullableList).containsNull() + } + } + } + + @Nested + inner class SerializationTests { + + @Nested + inner class DefaultOptionsTests { + + @Nested + inner class AnimeRawTests { + + @Test + fun `serialize AnimeRaw - all properties set`() { + runTest { + // given + val anime = TestAnimeRawObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + } + + @Test + fun `serialize AnimeRaw - default properties`() { + runTest { + // given + val anime = TestAnimeRawObjects.DefaultAnime.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) + } + } + } + + @Nested + inner class AnimeTests { + + @Test + fun `serialize Anime - all properties set`() { + runTest { + // given + val anime = TestAnimeObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint) + } + } + + @Test + fun `serialize Anime - default properties`() { + runTest { + // given + val anime = TestAnimeObjects.DefaultAnime.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.serializedPrettyPrint) + } + } + } + + @Test + fun `serialize - option serialize null is activated by default`() { + runTest { + // given + val expectedJson = """ + { + "nullableString": null, + "nonNullableString": "test" + } + """.trimIndent() + + // when + val result = Json.toJson(NullableTestClass()) + + // then + assertThat(result).isEqualTo(expectedJson) + } + } + } + + @Nested + inner class CustomOptionsTests { + + @Nested + inner class AnimeRawTests { + + @Test + fun `serialize AnimeRaw - deactivate pretty print`() { + runTest { + // given + val anime = TestAnimeRawObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedMinified) + } + } + + @Test + fun `serialize AnimeRaw - deactivate serialize null`() { + runTest { + // given + val anime = TestAnimeRawObjects.NullableNotSet.obj + + val expectedJson = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL" + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + // when + val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) + + // then + assertThat(result).isEqualTo(expectedJson) + } + } + } + + @Nested + inner class AnimeTests { + + @Test + fun `serialize Anime - deactivate pretty print`() { + runTest { + // given + val anime = TestAnimeObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedMinified) + } + } + + @Test + fun `serialize Anime - deactivate serialize null`() { + runTest { + // given + val anime = TestAnimeObjects.NullableNotSet.obj + + val expectedJson = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL" + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + // when + val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) + + // then + assertThat(result).isEqualTo(expectedJson) + } + } + } + } + } +} + +private data class NullableTestClass(val nullableString: String? = null, val nonNullableString: String = "test") +private data class ClassWithList(val nonNullableList: List<String> = emptyList()) \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapterTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapterTest.kt new file mode 100644 index 00000000..f90ee762 --- /dev/null +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/RelatedAnimeAdapterTest.kt @@ -0,0 +1,155 @@ +package io.github.manamiproject.modb.core.json + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.test.exceptionExpected +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test + +internal class RelatedAnimeAdapterTest { + + @Nested + inner class FromJsonTests { + + @Test + fun `correctly deserialize object with uri and relation`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.fromJson("""{"uri":"https://myanimelist.net/anime/2994","relation":"Summary"}""") + + // then + assertThat(result).isEqualTo(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")) + } + + @Test + fun `correctly deserialize object without relation`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.fromJson("""{"uri":"https://livechart.me/anime/3808"}""") + + // then + assertThat(result).isEqualTo(RelatedAnime(URI("https://livechart.me/anime/3808"))) + } + + @Test + fun `correctly deserialize object with relation null`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.fromJson("""{"uri":"https://livechart.me/anime/3808","relation":null}""") + + // then + assertThat(result).isEqualTo(RelatedAnime(URI("https://livechart.me/anime/3808"))) + } + + @Test + fun `blank relation results in null`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.fromJson("""{"uri":"https://livechart.me/anime/3808","relation":" "}""") + + // then + assertThat(result).isEqualTo(RelatedAnime(URI("https://livechart.me/anime/3808"))) + } + + @Test + fun `reads the previous format which only contains the link`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.fromJson("\"https://myanimelist.net/anime/2994\"") + + // then + assertThat(result).isEqualTo(RelatedAnime(URI("https://myanimelist.net/anime/2994"))) + } + + @Test + fun `ignores unknown properties`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.fromJson("""{"uri":"https://myanimelist.net/anime/2994","other":1,"relation":"Summary"}""") + + // then + assertThat(result).isEqualTo(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")) + } + + @Test + fun `throws exception if uri is missing`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson("""{"relation":"Summary"}""") + } + + // then + assertThat(result).hasMessage("Property 'uri' is either missing or null.") + } + } + + @Nested + inner class ToJsonTests { + + @Test + fun `correctly serialize uri and relation`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.toJson(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")) + + // then + assertThat(result).isEqualTo("""{"uri":"https://myanimelist.net/anime/2994","relation":"Summary"}""") + } + + @Test + fun `omits relation if it is unknown`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = adapter.toJson(RelatedAnime(URI("https://livechart.me/anime/3808"))) + + // then + assertThat(result).isEqualTo("""{"uri":"https://livechart.me/anime/3808"}""") + } + + @Test + fun `omits relation if it is unknown even if serializeNull is set`() { + // given + val adapter = RelatedAnimeAdapter().serializeNulls() + + // when + val result = adapter.toJson(RelatedAnime(URI("https://livechart.me/anime/3808"))) + + // then + assertThat(result).isEqualTo("""{"uri":"https://livechart.me/anime/3808"}""") + } + + @Test + fun `throws exception if value is null`() { + // given + val adapter = RelatedAnimeAdapter() + + // when + val result = exceptionExpected<IllegalArgumentException> { + adapter.toJson(null) + } + + // then + assertThat(result).hasMessage("RelatedAnimeAdapter expects non-nullable value, but received null.") + } + } +} diff --git a/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt b/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt index 897770aa..95c83b96 100644 --- a/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt +++ b/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt @@ -1,247 +1,247 @@ -package io.github.manamiproject.modb.kitsu - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.* -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import kotlinx.coroutines.withContext -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extractor which retrieves the data from raw data. - */ -public class KitsuAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = KitsuConfig, - private val extractor: DataExtractor = JsonDataExtractor, -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract(rawContent, mapOf( - "title" to "$.data[0].attributes.canonicalTitle", - "episodeCount" to "$.data[0].attributes.episodeCount", - "subtype" to "$.data[0].attributes.subtype", - "id" to "$.data[0].id", - "pictureSmall" to "$.data[0].attributes.posterImage.small", - "pictureTiny" to "$.data[0].attributes.posterImage.tiny", - "abbreviatedTitles" to "$.data[0].attributes.abbreviatedTitles", - "titles" to "$.data[0].attributes.titles", - "status" to "$.data[0].attributes.status", - "episodeLength" to "$.data[0].attributes.episodeLength", - "startDate" to "$.data[0].attributes.startDate", - "score" to "$.data[0].attributes.averageRating", - "included" to "$.included" - )) - - return@withContext AnimeRaw( - _title = extractTitle(data), - episodes = extractEpisodes(data), - type = extractType(data), - picture = extractPicture(data), - thumbnail = extractThumbnail(data), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = extractSynonyms(data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun extractTitle(data: ExtractionResult): Title = data.string("title") - - private fun extractEpisodes(data: ExtractionResult): Episodes = data.intOrDefault("episodeCount") - - private fun extractPicture(data: ExtractionResult): URI { - return if (data.notFound("pictureSmall")) { - NO_PICTURE - } else { - URI(data.string("pictureSmall").trim()) - } - } - - private fun extractThumbnail(data: ExtractionResult): URI { - return if (data.notFound("pictureTiny")) { - NO_PICTURE_THUMBNAIL - } else { - URI(data.string("pictureTiny").trim()) - } - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> = hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) - - private fun extractType(data: ExtractionResult): AnimeType { - return when(data.string("subtype").trim().lowercase()) { - "tv" -> TV - "ona" -> ONA - "movie" -> MOVIE - "ova" -> OVA - "special" -> SPECIAL - "music" -> SPECIAL - else -> throw IllegalStateException("Unknown type [${data.string("subtype")}]") - } - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - return data.listNotNull<Title>("abbreviatedTitles").union( - data.listNotNull<LinkedHashMap<String, Title>>("titles").first().values.filterNotNull()).toHashSet() - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - return included.filter { it["type"] == "mediaRelationships" } - .map { it as Map<*,*> } - .map { it["relationships"] as Map<*,*> } - .map { it["destination"] as Map<*,*> } - .map { it["data"] as Map<*,*> } - .filter { it["type"] as String == "anime" } - .map { it["id"] as AnimeId } - .map { metaDataProviderConfig.buildAnimeLink(it) } - .toHashSet() - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - if (data.notFound("status")) { - return UNKNOWN_STATUS - } - - return when(data.string("status").trim().lowercase()) { - "finished" -> FINISHED - "current" -> ONGOING - "unreleased" -> UPCOMING - "upcoming" -> UPCOMING - "tba" -> UNKNOWN_STATUS - else -> throw IllegalStateException("Unknown status [${data.string("status")}]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationInMinutes = data.intOrDefault("episodeLength") - return Duration(durationInMinutes, MINUTES) - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - val genres = included.filter { it["type"] == "genres" } - .map { it as Map<*,*> } - .map { it["attributes"] as Map<*,*> } - .map { it["name"] as Tag } - - val categories = included.filter { it["type"] == "categories" } - .map { it as Map<*,*> } - .map { it["attributes"] as Map<*,*> } - .map { it["title"] as Tag } - - return genres.union(categories).toHashSet() - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val startDate = data.stringOrDefault("startDate", EMPTY) - val month = """-\d{2}-""".toRegex().findAll(startDate).firstOrNull()?.value?.remove("-")?.toInt() ?: 0 - val year = """\d{4}""".toRegex().find(startDate)?.value?.let { if (it.startsWith("0")) "0" else it }?.toInt() ?: 0 - - val season = when(month) { - 12, 1, 2 -> WINTER - 3, 4, 5 -> SPRING - 6, 7, 8 -> SUMMER - 9, 10, 11 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - season = season, - year = year - ) - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = 1.0..100.0, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - val producers = included.filter { it["type"] == "producers" } - .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } - - val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } - .map { it as Map<*,*> } - .filter { (it["attributes"] as Map<*,*>)["role"] as String == "studio" } - .map { it["relationships"] as Map<*,*> } - .map { it["producer"] as Map<*,*> } - .map { it["data"] as Map<*,*> } - .map { it["id"] as String } - - return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - val producers = included.filter { it["type"] == "producers" } - .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } - - val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } - .map { it as Map<*,*> } - .filter { (it["attributes"] as Map<*,*>)["role"] as String == "producer" } - .map { it["relationships"] as Map<*,*> } - .map { it["producer"] as Map<*,*> } - .map { it["data"] as Map<*,*> } - .map { it["id"] as String } - - return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() - } - - public companion object { - /** - * Singleton of [KitsuAnimeConverter] - * @since 7.0.0 - */ - public val instance: KitsuAnimeConverter by lazy { KitsuAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.kitsu + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.* +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import kotlinx.coroutines.withContext +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extractor which retrieves the data from raw data. + */ +public class KitsuAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = KitsuConfig, + private val extractor: DataExtractor = JsonDataExtractor, +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract(rawContent, mapOf( + "title" to "$.data[0].attributes.canonicalTitle", + "episodeCount" to "$.data[0].attributes.episodeCount", + "subtype" to "$.data[0].attributes.subtype", + "id" to "$.data[0].id", + "pictureSmall" to "$.data[0].attributes.posterImage.small", + "pictureTiny" to "$.data[0].attributes.posterImage.tiny", + "abbreviatedTitles" to "$.data[0].attributes.abbreviatedTitles", + "titles" to "$.data[0].attributes.titles", + "status" to "$.data[0].attributes.status", + "episodeLength" to "$.data[0].attributes.episodeLength", + "startDate" to "$.data[0].attributes.startDate", + "score" to "$.data[0].attributes.averageRating", + "included" to "$.included" + )) + + return@withContext AnimeRaw( + _title = extractTitle(data), + episodes = extractEpisodes(data), + type = extractType(data), + picture = extractPicture(data), + thumbnail = extractThumbnail(data), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = extractSynonyms(data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun extractTitle(data: ExtractionResult): Title = data.string("title") + + private fun extractEpisodes(data: ExtractionResult): Episodes = data.intOrDefault("episodeCount") + + private fun extractPicture(data: ExtractionResult): URI { + return if (data.notFound("pictureSmall")) { + NO_PICTURE + } else { + URI(data.string("pictureSmall").trim()) + } + } + + private fun extractThumbnail(data: ExtractionResult): URI { + return if (data.notFound("pictureTiny")) { + NO_PICTURE_THUMBNAIL + } else { + URI(data.string("pictureTiny").trim()) + } + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> = hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) + + private fun extractType(data: ExtractionResult): AnimeType { + return when(data.string("subtype").trim().lowercase()) { + "tv" -> TV + "ona" -> ONA + "movie" -> MOVIE + "ova" -> OVA + "special" -> SPECIAL + "music" -> SPECIAL + else -> throw IllegalStateException("Unknown type [${data.string("subtype")}]") + } + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + return data.listNotNull<Title>("abbreviatedTitles").union( + data.listNotNull<LinkedHashMap<String, Title>>("titles").first().values.filterNotNull()).toHashSet() + } + + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + return included.filter { it["type"] == "mediaRelationships" } + .map { it as Map<*,*> } + .mapNotNull { relationship -> + val destination = ((relationship["relationships"] as Map<*,*>)["destination"] as Map<*,*>)["data"] as Map<*,*> + if (destination["type"] as String != "anime") return@mapNotNull null + val role = (relationship["attributes"] as? Map<*,*>)?.get("role")?.toString() + RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(destination["id"] as AnimeId), role) + } + .toHashSet() + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + if (data.notFound("status")) { + return UNKNOWN_STATUS + } + + return when(data.string("status").trim().lowercase()) { + "finished" -> FINISHED + "current" -> ONGOING + "unreleased" -> UPCOMING + "upcoming" -> UPCOMING + "tba" -> UNKNOWN_STATUS + else -> throw IllegalStateException("Unknown status [${data.string("status")}]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationInMinutes = data.intOrDefault("episodeLength") + return Duration(durationInMinutes, MINUTES) + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + val genres = included.filter { it["type"] == "genres" } + .map { it as Map<*,*> } + .map { it["attributes"] as Map<*,*> } + .map { it["name"] as Tag } + + val categories = included.filter { it["type"] == "categories" } + .map { it as Map<*,*> } + .map { it["attributes"] as Map<*,*> } + .map { it["title"] as Tag } + + return genres.union(categories).toHashSet() + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val startDate = data.stringOrDefault("startDate", EMPTY) + val month = """-\d{2}-""".toRegex().findAll(startDate).firstOrNull()?.value?.remove("-")?.toInt() ?: 0 + val year = """\d{4}""".toRegex().find(startDate)?.value?.let { if (it.startsWith("0")) "0" else it }?.toInt() ?: 0 + + val season = when(month) { + 12, 1, 2 -> WINTER + 3, 4, 5 -> SPRING + 6, 7, 8 -> SUMMER + 9, 10, 11 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + season = season, + year = year + ) + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = 1.0..100.0, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + val producers = included.filter { it["type"] == "producers" } + .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } + + val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } + .map { it as Map<*,*> } + .filter { (it["attributes"] as Map<*,*>)["role"] as String == "studio" } + .map { it["relationships"] as Map<*,*> } + .map { it["producer"] as Map<*,*> } + .map { it["data"] as Map<*,*> } + .map { it["id"] as String } + + return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + val producers = included.filter { it["type"] == "producers" } + .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } + + val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } + .map { it as Map<*,*> } + .filter { (it["attributes"] as Map<*,*>)["role"] as String == "producer" } + .map { it["relationships"] as Map<*,*> } + .map { it["producer"] as Map<*,*> } + .map { it["data"] as Map<*,*> } + .map { it["id"] as String } + + return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() + } + + public companion object { + /** + * Singleton of [KitsuAnimeConverter] + * @since 7.0.0 + */ + public val instance: KitsuAnimeConverter by lazy { KitsuAnimeConverter() } + } +} \ No newline at end of file diff --git a/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt b/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt index 9c9aa202..86e1054c 100644 --- a/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt +++ b/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt @@ -1,1333 +1,1334 @@ -package io.github.manamiproject.modb.kitsu - -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS - -internal class KitsuAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/title/special_chars.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `episodes is null`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isZero() - } - } - - @Test - fun `39 episodes`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/39.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(39) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is tv`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/tv.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is special`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/special.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is ona`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ona.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is ova`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ova.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is movie`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/movie.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is music is mapped to special`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/music.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `posterImage is null`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `posterImage is set`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/pictures.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/small.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/tiny.jpg")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `all non main titles and the synonyms are combined`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/combine_titles_and_synonyms.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Maho Yome", - "MahoYome", - "Mahou Tsukai no Yome", - "The Ancient Magus' Bride", - "The Magician's Bride", - "まほよめ", - "魔法使いの嫁", - ) - } - } - - @Test - fun `abbreviatedTitles contains null`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/abbreviatedTitles_contains_null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Detective Conan Movie 04: Captured in Her Eyes", - "Detective Conan Movie 4", - "Meitantei Conan: Hitomi no Naka no Ansatsusha", - "瞳の中の暗殺者", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `build correct source link`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/sources/1517.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.sources).containsExactly(URI("https://kitsu.app/anime/1517")) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no adaption, no relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_no_relations.json") - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no adaption, multiple relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://kitsu.app/anime/10761"), - URI("https://kitsu.app/anime/12549"), - URI("https://kitsu.app/anime/13562"), - URI("https://kitsu.app/anime/7742"), - URI("https://kitsu.app/anime/7913"), - URI("https://kitsu.app/anime/8273"), - ) - } - } - - @Test - fun `one adaption, one relation`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/one_adaption_one_relation.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactly(URI("https://kitsu.app/anime/47280")) - } - } - - @Test - fun `has adaption, multiple relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_multiple_relations.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://kitsu.app/anime/13850"), - URI("https://kitsu.app/anime/1759"), - URI("https://kitsu.app/anime/1921"), - URI("https://kitsu.app/anime/2634"), - URI("https://kitsu.app/anime/3700"), - URI("https://kitsu.app/anime/42535"), - URI("https://kitsu.app/anime/5518"), - URI("https://kitsu.app/anime/6791"), - URI("https://kitsu.app/anime/7627"), - ) - } - } - - @Test - fun `has adaption, no relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'finished' is mapped to 'FINISHED_AIRING'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/finished.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `'current' is mapped to 'ONGOING'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/current.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'unreleased' is mapped to 'NOT_YET_AIRED'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/unreleased.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'upcoming' is mapped to 'NOT_YET_AIRED'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/upcoming.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'tba' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/tba.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `null is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `duration is not set and therefore 0`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `kitsu only uses minutes for duration - 0 implies a duration of less than a minute`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/0.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `duration of 24 minutes`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/24.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) - } - } - - @Test - fun `duration of 2 hours`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/120.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `multiple tags`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/multiple_tags.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "adventure", - "bounty hunter", - "future", - "gunfights", - "other planet", - "science fiction", - "shipboard", - "space", - "space travel", - "comedy", - "post apocalypse", - "drama", - "detective", - "sci-fi", - ) - } - } - - @Test - fun `no tags`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_tags.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `startDate is null`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - - @Test - fun `year of premiere is 1989`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/year_of_premiere/1989.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(1989) - } - } - - @Test - fun `year has a wrong format`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/invalid_format.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.isYearOfPremiereUnknown()) - assertThat(result.animeSeason.year).isZero() - } - } - } - - @Nested - inner class SeasonTests { - - @Test - fun `season is 'undefined'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'spring'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/spring.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'summer'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/summer.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'fall'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/fall.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'winter'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/winter.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/score.json")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(7.897272727272727) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/no_score.json")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/multiple_studios.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "shin-ei animation", - "studio pierrot", - ) - } - } - - @Test - fun `no studios`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_studios.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/multiple_producers.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "vap", - "konami", - "nippon television network corporation", - "shueisha", - "madhouse", - "ashi productions", - ) - } - } - - @Test - fun `no producers`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_producers.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = KitsuAnimeConverter.instance - - // when - val result = KitsuAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(KitsuAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.kitsu + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS + +internal class KitsuAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/title/special_chars.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `episodes is null`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isZero() + } + } + + @Test + fun `39 episodes`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/39.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(39) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is tv`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/tv.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is special`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/special.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is ona`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ona.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is ova`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ova.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is movie`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/movie.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is music is mapped to special`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/music.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `posterImage is null`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `posterImage is set`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/pictures.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/small.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/tiny.jpg")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `all non main titles and the synonyms are combined`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/combine_titles_and_synonyms.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Maho Yome", + "MahoYome", + "Mahou Tsukai no Yome", + "The Ancient Magus' Bride", + "The Magician's Bride", + "まほよめ", + "魔法使いの嫁", + ) + } + } + + @Test + fun `abbreviatedTitles contains null`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/abbreviatedTitles_contains_null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Detective Conan Movie 04: Captured in Her Eyes", + "Detective Conan Movie 4", + "Meitantei Conan: Hitomi no Naka no Ansatsusha", + "瞳の中の暗殺者", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `build correct source link`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/sources/1517.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.sources).containsExactly(URI("https://kitsu.app/anime/1517")) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no adaption, no relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_no_relations.json") + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no adaption, multiple relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://kitsu.app/anime/10761"), "prequel"), + RelatedAnime(URI("https://kitsu.app/anime/12549"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/13562"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/7742"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/7913"), "side_story"), + RelatedAnime(URI("https://kitsu.app/anime/8273"), "sequel"), + ) + } + } + + @Test + fun `one adaption, one relation`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/one_adaption_one_relation.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactly(RelatedAnime(URI("https://kitsu.app/anime/47280"), "other")) + } + } + + @Test + fun `has adaption, multiple relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_multiple_relations.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://kitsu.app/anime/13850"), "alternative_version"), + RelatedAnime(URI("https://kitsu.app/anime/1759"), "side_story"), + RelatedAnime(URI("https://kitsu.app/anime/1921"), "summary"), + RelatedAnime(URI("https://kitsu.app/anime/2634"), "sequel"), + RelatedAnime(URI("https://kitsu.app/anime/3700"), "summary"), + RelatedAnime(URI("https://kitsu.app/anime/42535"), "side_story"), + RelatedAnime(URI("https://kitsu.app/anime/5518"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/6791"), "spinoff"), + RelatedAnime(URI("https://kitsu.app/anime/7627"), "spinoff"), + ) + } + } + + @Test + fun `has adaption, no relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'finished' is mapped to 'FINISHED_AIRING'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/finished.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `'current' is mapped to 'ONGOING'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/current.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'unreleased' is mapped to 'NOT_YET_AIRED'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/unreleased.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'upcoming' is mapped to 'NOT_YET_AIRED'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/upcoming.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'tba' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/tba.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `null is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `duration is not set and therefore 0`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `kitsu only uses minutes for duration - 0 implies a duration of less than a minute`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/0.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `duration of 24 minutes`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/24.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) + } + } + + @Test + fun `duration of 2 hours`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/120.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `multiple tags`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/multiple_tags.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "adventure", + "bounty hunter", + "future", + "gunfights", + "other planet", + "science fiction", + "shipboard", + "space", + "space travel", + "comedy", + "post apocalypse", + "drama", + "detective", + "sci-fi", + ) + } + } + + @Test + fun `no tags`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_tags.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `startDate is null`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + + @Test + fun `year of premiere is 1989`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/year_of_premiere/1989.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(1989) + } + } + + @Test + fun `year has a wrong format`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/invalid_format.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.isYearOfPremiereUnknown()) + assertThat(result.animeSeason.year).isZero() + } + } + } + + @Nested + inner class SeasonTests { + + @Test + fun `season is 'undefined'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'spring'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/spring.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'summer'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/summer.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'fall'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/fall.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'winter'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/winter.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/score.json")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(7.897272727272727) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/no_score.json")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/multiple_studios.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "shin-ei animation", + "studio pierrot", + ) + } + } + + @Test + fun `no studios`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_studios.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/multiple_producers.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "vap", + "konami", + "nippon television network corporation", + "shueisha", + "madhouse", + "ashi productions", + ) + } + } + + @Test + fun `no producers`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_producers.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = KitsuAnimeConverter.instance + + // when + val result = KitsuAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(KitsuAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt b/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt index 975b8b5b..7c061605 100644 --- a/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt +++ b/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt @@ -1,22 +1,22 @@ -package io.github.manamiproject.modb.app.dataset - -import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.simkl.SimklConfig - -object AnimeCountdownUrlAdder { - - fun addAnimeCountdown(list: List<AnimeRaw>): List<AnimeRaw> { - return list.map { anime -> - val sources = anime.sources.filter { source -> source.host == SimklConfig.hostname() } - .map { SimklConfig.extractAnimeId(it) } - .map { AnimeCountdownConfig.buildAnimeLink(it) } - val relatedAnime = anime.relatedAnime.filter { source -> source.host == SimklConfig.hostname() } - .map { SimklConfig.extractAnimeId(it) } - .map { AnimeCountdownConfig.buildAnimeLink(it) } - - anime.addSources(sources) - anime.addRelatedAnime(relatedAnime) - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.dataset + +import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.simkl.SimklConfig + +object AnimeCountdownUrlAdder { + + fun addAnimeCountdown(list: List<AnimeRaw>): List<AnimeRaw> { + return list.map { anime -> + val sources = anime.sources.filter { source -> source.host == SimklConfig.hostname() } + .map { SimklConfig.extractAnimeId(it) } + .map { AnimeCountdownConfig.buildAnimeLink(it) } + val relatedAnime = anime.relatedAnime.filter { related -> related.uri.host == SimklConfig.hostname() } + .map { related -> RelatedAnime(AnimeCountdownConfig.buildAnimeLink(SimklConfig.extractAnimeId(related.uri)), related.relation) } + + anime.addSources(sources) + anime.addRelatedAnime(relatedAnime) + } + } +} \ No newline at end of file diff --git a/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt b/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt index f36eaef6..a2e85d2f 100644 --- a/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt +++ b/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt @@ -1,513 +1,555 @@ -package io.github.manamiproject.modb.app.readme - -import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig -import io.github.manamiproject.modb.app.config.AppConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.downloadcontrolstate.DefaultDownloadControlStateAccessor -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor -import io.github.manamiproject.modb.core.date.weekOfYear -import io.github.manamiproject.modb.app.merging.DefaultReviewedIsolatedEntriesAccessor -import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor -import io.github.manamiproject.modb.app.merging.lock.DefaultMergeLockAccessor -import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.extensions.readFile -import io.github.manamiproject.modb.core.extensions.regularFileExists -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.core.logging.LoggerDelegate -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.Year -import io.github.manamiproject.modb.core.config.DatasetRepository -import io.github.manamiproject.modb.simkl.SimklConfig -import java.time.LocalDate - -/** - * Creates the `README.md` for the dataset repo including some statistics and dynamically retrieved content. - * @since 1.0.0 - * @property appConfig Application specific configuration. Uses [AppConfig] by default. - * @property mergeLockAccessor Access to merge locks. - * @property downloadControlStateAccessor Access to DCS files. - * @property reviewedIsolatedEntriesAccessor Access to reviewed isolated entries. - */ -class DefaultReadmeCreator( - private val appConfig: Config = AppConfig.instance, - private val mergeLockAccessor: MergeLockAccessor = DefaultMergeLockAccessor.instance, - private val downloadControlStateAccessor: DownloadControlStateAccessor = DefaultDownloadControlStateAccessor.instance, - private val reviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor = DefaultReviewedIsolatedEntriesAccessor.instance, -): ReadmeCreator { - - override suspend fun updateWith(mergedAnime: List<Anime>) { - val readme = appConfig.outputDirectory().resolve("README.md") - val statistics = createStatisticsBlock(mergedAnime) - - if (!readme.regularFileExists()) { - StringBuilder(createHeaderBlock()) - .append(statistics) - .append(createStructureBlock()) - .toString() - .writeToFile(readme) - return - } - - val currentContent = readme.readFile() - val start = currentContent.indexOf(STATISTICS_START) - val end = currentContent.indexOf(STATISTICS_END) - - if (start == -1 || end == -1 || end < start) { - log.info { "README.md has no statistics block. Leaving it untouched." } - return - } - - // readFile strips a trailing newline, so it is restored here to keep the file unchanged - // apart from the statistics. - val updatedContent = currentContent.substring(0, start + STATISTICS_START.length) + - "\n" + statistics.trim() + "\n" + - currentContent.substring(end) + "\n" - - updatedContent.writeToFile(readme) - } - - private fun createHeaderBlock(): String { - return """ - [![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](${DatasetRepository.license("${getWeekAndYear().second}-${getWeekAndYear().first}")}) - - # anime-offline-database - The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - """.trimIndent() - } - - private suspend fun createStatisticsBlock(mergedAnime: List<Anime>): String { - val weekAndYear = getWeekAndYear() - val numberOfEntriesAndPercentReviewed = getNumberOfEntriesAndPercentReviewed(mergedAnime) - - val block = StringBuilder(""" - - - ## Statistics - Update **week ${weekAndYear.first} [${weekAndYear.second}]** - - The dataset consists of **${numberOfEntriesAndPercentReviewed.first}** entries _(${numberOfEntriesAndPercentReviewed.second}% reviewed)_ composed of: - - | Number of entries | Metadata provider | - |-------------------|-------------------| - - """.trimIndent()) - - appConfig.metaDataProviderConfigurations() - .map { getNumberForMetaDataProvider(it) } - .toMutableList() - .apply { - add(Pair(AnimeCountdownConfig.hostname(), find { it.first == SimklConfig.hostname() }?.second ?: 0)) - } - .sortedByDescending { it.second } - .forEach { - block.append("| ${it.second} | [${it.first}](https://${it.first}) |") - .append("\n") - } - - return block.toString() - } - - private fun createStructureBlock(): String { - return $$""" - - - ## Files - - This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - - | File | Type reference | Description | - |-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| - | `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | - | `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | - | `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | - | `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | - | `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | - | `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | - | `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | - | `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | - | `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - - ## Type reference - - Here is a description of the types in the JSON files. - - ### Database root - - | Field | Type | Nullable | Description | - |--------------|-----------------------------|----------|-------------------------------------------------------------------| - | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | - | `license` | [License](#license) | no | Information about the license of the dataset. | - | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | - | `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | - | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | - | `data` | [Anime[]](#anime) | no | List of all anime. | - - ### License - - | Field | Type | Nullable | Description | - |--------|----------|----------|--------------------------| - | `name` | `String` | no | Name of the license. | - | `url` | `URL` | no | URL to the license file. | - - ### ScoreRange - - | Field | Type | Nullable | Description | - |----------------|----------|----------|----------------------------------------------------------| - | `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | - | `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - - ### Anime - - | Field | Type | Nullable | Description | - |----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| - | `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | - | `title` | `String` | no | Main title. | - | `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | - | `episodes` | `Integer` | no | Number of episodes, movies or parts. | - | `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | - | `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | - | `picture` | `URL` | no | URL of a picture which represents the anime. | - | `thumbnail` | `URL` | no | URL of a smaller version of the picture. | - | `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | - | `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | - | `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | - | `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | - | `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | - | `relatedAnime` | `URL[]` | no | URLs to the metadata providers for anime that are somehow related to this anime. | - | `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - - ### AnimeSeason - - | Field | Type | Nullable | Description | - |----------|-----------------------------------------------------|----------|---------------------------------------------| - | `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | - | `year` | `Integer` | yes | Year of first release. | - - ### Duration - - | Field | Type | Nullable | Description | - |----------|-----------|----------|-------------------------------------------------------| - | `value` | `Integer` | no | Duration in seconds. | - | `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - - ### Score - - | Field | Type | Nullable | Description | - |---------------------------|----------|----------|--------------------------------------------------------------------------------------| - | `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | - | `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | - | `median` | `Double` | no | Median based on all available scores from all metadata providers. | - - ### DeadEntries root - - | Field | Type | Nullable | Description | - |---------------|-----------------------------|----------|-----------------------------------------------------------------------------| - | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | - | `license` | [License](#license) | no | Information about the license of the dataset. | - | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | - | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | - | `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - - ## Examples - - Here are some examples showing what the files look like. - - ### anime-offline-database.json: - - <details open> - <summary>Example</summary> - - ```json - { - "$schema": "$${DatasetRepository.schema("2025-18", "anime-offline-database.schema.json")}", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "$${DatasetRepository.license("2025-18")}" - }, - "repository": "$${DatasetRepository.url()}", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://anidb.net/anime/8146", - "https://anidb.net/anime/8147", - "https://anilist.co/anime/20931", - "https://anilist.co/anime/2994", - "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god", - "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors", - "https://animecountdown.com/36687", - "https://animecountdown.com/40690", - "https://anisearch.com/anime/4441", - "https://anisearch.com/anime/5194", - "https://kitsu.app/anime/2707", - "https://livechart.me/anime/3808", - "https://myanimelist.net/anime/2994", - "https://simkl.com/anime/36687", - "https://simkl.com/anime/40690" - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] - } - ``` - </details> - - ### dead-entries/*.json - - <details> - <summary>Example</summary> - - ```json - { - "$schema": "$${DatasetRepository.schema("2025-18", "dead-entries.schema.json")}", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "$${DatasetRepository.license("2025-18")}" - }, - "repository": "$${DatasetRepository.url()}", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] - } - ``` - </details> - """.trimIndent() - } - - private fun getWeekAndYear(): Pair<String, Year> { - val currentDate = LocalDate.now(appConfig.clock()) - val weekOfYear = currentDate.weekOfYear() - val weekWithLeadingZero = if (weekOfYear.week < 10) "0${weekOfYear.week}" else weekOfYear.week.toString() - - return Pair(weekWithLeadingZero, weekOfYear.year) - } - - private suspend fun getNumberForMetaDataProvider(metaDataProviderConfig: MetaDataProviderConfig): Pair<Hostname, Int> { - val numberOfEntries = downloadControlStateAccessor.allAnime(metaDataProviderConfig).count() - return Pair(metaDataProviderConfig.hostname(), numberOfEntries) - } - - private suspend fun getNumberOfEntriesAndPercentReviewed(mergedAnime: List<Anime>): Pair<Int, Int> { - val numberOfUnreviewedEntries = mergedAnime - .map { it.sources } - .map { it.filterNot { uri -> uri.host == AnimeCountdownConfig.hostname() }.toHashSet() } - .map { - return@map if (it.size == 1) { - reviewedIsolatedEntriesAccessor.contains(it.first()) || mergeLockAccessor.hasMergeLock(it) - } else { - mergeLockAccessor.hasMergeLock(it) - } - } - .filterNot { it } - .count() - - val reviewedEntriesInPercent = (100.0 - (numberOfUnreviewedEntries.toDouble() / mergedAnime.size.toDouble() * 100.0)).toInt() - - return Pair(mergedAnime.size, reviewedEntriesInPercent) - } - - companion object { - private val log by LoggerDelegate() - - /** - * Opening marker of the generated statistics section within an existing `README.md`. - * @since 1.0.0 - */ - const val STATISTICS_START: String = "<!-- statistics -->" - - /** - * Closing marker of the generated statistics section within an existing `README.md`. - * @since 1.0.0 - */ - const val STATISTICS_END: String = "<!-- /statistics -->" - - /** - * Singleton of [DefaultReadmeCreator] - * @since 1.0.0 - */ - val instance: DefaultReadmeCreator by lazy { DefaultReadmeCreator() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.readme + +import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig +import io.github.manamiproject.modb.app.config.AppConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.downloadcontrolstate.DefaultDownloadControlStateAccessor +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor +import io.github.manamiproject.modb.core.date.weekOfYear +import io.github.manamiproject.modb.app.merging.DefaultReviewedIsolatedEntriesAccessor +import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor +import io.github.manamiproject.modb.app.merging.lock.DefaultMergeLockAccessor +import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.extensions.readFile +import io.github.manamiproject.modb.core.extensions.regularFileExists +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.core.logging.LoggerDelegate +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.Year +import io.github.manamiproject.modb.core.config.DatasetRepository +import io.github.manamiproject.modb.simkl.SimklConfig +import java.time.LocalDate + +/** + * Creates the `README.md` for the dataset repo including some statistics and dynamically retrieved content. + * @since 1.0.0 + * @property appConfig Application specific configuration. Uses [AppConfig] by default. + * @property mergeLockAccessor Access to merge locks. + * @property downloadControlStateAccessor Access to DCS files. + * @property reviewedIsolatedEntriesAccessor Access to reviewed isolated entries. + */ +class DefaultReadmeCreator( + private val appConfig: Config = AppConfig.instance, + private val mergeLockAccessor: MergeLockAccessor = DefaultMergeLockAccessor.instance, + private val downloadControlStateAccessor: DownloadControlStateAccessor = DefaultDownloadControlStateAccessor.instance, + private val reviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor = DefaultReviewedIsolatedEntriesAccessor.instance, +): ReadmeCreator { + + override suspend fun updateWith(mergedAnime: List<Anime>) { + val readme = appConfig.outputDirectory().resolve("README.md") + val statistics = createStatisticsBlock(mergedAnime) + + if (!readme.regularFileExists()) { + StringBuilder(createHeaderBlock()) + .append(statistics) + .append(createStructureBlock()) + .toString() + .writeToFile(readme) + return + } + + val currentContent = readme.readFile() + val start = currentContent.indexOf(STATISTICS_START) + val end = currentContent.indexOf(STATISTICS_END) + + if (start == -1 || end == -1 || end < start) { + log.info { "README.md has no statistics block. Leaving it untouched." } + return + } + + // readFile strips a trailing newline, so it is restored here to keep the file unchanged + // apart from the statistics. + val updatedContent = currentContent.substring(0, start + STATISTICS_START.length) + + "\n" + statistics.trim() + "\n" + + currentContent.substring(end) + "\n" + + updatedContent.writeToFile(readme) + } + + private fun createHeaderBlock(): String { + return """ + [![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](${DatasetRepository.license("${getWeekAndYear().second}-${getWeekAndYear().first}")}) + + # anime-offline-database + The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + """.trimIndent() + } + + private suspend fun createStatisticsBlock(mergedAnime: List<Anime>): String { + val weekAndYear = getWeekAndYear() + val numberOfEntriesAndPercentReviewed = getNumberOfEntriesAndPercentReviewed(mergedAnime) + + val block = StringBuilder(""" + + + ## Statistics + Update **week ${weekAndYear.first} [${weekAndYear.second}]** + + The dataset consists of **${numberOfEntriesAndPercentReviewed.first}** entries _(${numberOfEntriesAndPercentReviewed.second}% reviewed)_ composed of: + + | Number of entries | Metadata provider | + |-------------------|-------------------| + + """.trimIndent()) + + appConfig.metaDataProviderConfigurations() + .map { getNumberForMetaDataProvider(it) } + .toMutableList() + .apply { + add(Pair(AnimeCountdownConfig.hostname(), find { it.first == SimklConfig.hostname() }?.second ?: 0)) + } + .sortedByDescending { it.second } + .forEach { + block.append("| ${it.second} | [${it.first}](https://${it.first}) |") + .append("\n") + } + + return block.toString() + } + + private fun createStructureBlock(): String { + return $$""" + + + ## Files + + This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + + | File | Type reference | Description | + |-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| + | `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | + | `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | + | `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | + | `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | + | `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | + | `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | + | `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | + | `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | + | `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + + ## Type reference + + Here is a description of the types in the JSON files. + + ### Database root + + | Field | Type | Nullable | Description | + |--------------|-----------------------------|----------|-------------------------------------------------------------------| + | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | + | `license` | [License](#license) | no | Information about the license of the dataset. | + | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | + | `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | + | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | + | `data` | [Anime[]](#anime) | no | List of all anime. | + + ### License + + | Field | Type | Nullable | Description | + |--------|----------|----------|--------------------------| + | `name` | `String` | no | Name of the license. | + | `url` | `URL` | no | URL to the license file. | + + ### ScoreRange + + | Field | Type | Nullable | Description | + |----------------|----------|----------|----------------------------------------------------------| + | `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | + | `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + + ### Anime + + | Field | Type | Nullable | Description | + |----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| + | `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | + | `title` | `String` | no | Main title. | + | `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | + | `episodes` | `Integer` | no | Number of episodes, movies or parts. | + | `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | + | `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | + | `picture` | `URL` | no | URL of a picture which represents the anime. | + | `thumbnail` | `URL` | no | URL of a smaller version of the picture. | + | `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | + | `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | + | `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | + | `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | + | `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | + | `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | + | `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + + ### RelatedAnime + + | Field | Type | Nullable | Description | + |------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| + | `uri` | `URL` | no | URL to the related anime on the metadata provider. | + | `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + + ### AnimeSeason + + | Field | Type | Nullable | Description | + |----------|-----------------------------------------------------|----------|---------------------------------------------| + | `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | + | `year` | `Integer` | yes | Year of first release. | + + ### Duration + + | Field | Type | Nullable | Description | + |----------|-----------|----------|-------------------------------------------------------| + | `value` | `Integer` | no | Duration in seconds. | + | `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + + ### Score + + | Field | Type | Nullable | Description | + |---------------------------|----------|----------|--------------------------------------------------------------------------------------| + | `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | + | `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | + | `median` | `Double` | no | Median based on all available scores from all metadata providers. | + + ### DeadEntries root + + | Field | Type | Nullable | Description | + |---------------|-----------------------------|----------|-----------------------------------------------------------------------------| + | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | + | `license` | [License](#license) | no | Information about the license of the dataset. | + | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | + | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | + | `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + + ## Examples + + Here are some examples showing what the files look like. + + ### anime-offline-database.json: + + <details open> + <summary>Example</summary> + + ```json + { + "$schema": "$${DatasetRepository.schema("2025-18", "anime-offline-database.schema.json")}", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "$${DatasetRepository.license("2025-18")}" + }, + "repository": "$${DatasetRepository.url()}", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] + } + ``` + </details> + + ### dead-entries/*.json + + <details> + <summary>Example</summary> + + ```json + { + "$schema": "$${DatasetRepository.schema("2025-18", "dead-entries.schema.json")}", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "$${DatasetRepository.license("2025-18")}" + }, + "repository": "$${DatasetRepository.url()}", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] + } + ``` + </details> + """.trimIndent() + } + + private fun getWeekAndYear(): Pair<String, Year> { + val currentDate = LocalDate.now(appConfig.clock()) + val weekOfYear = currentDate.weekOfYear() + val weekWithLeadingZero = if (weekOfYear.week < 10) "0${weekOfYear.week}" else weekOfYear.week.toString() + + return Pair(weekWithLeadingZero, weekOfYear.year) + } + + private suspend fun getNumberForMetaDataProvider(metaDataProviderConfig: MetaDataProviderConfig): Pair<Hostname, Int> { + val numberOfEntries = downloadControlStateAccessor.allAnime(metaDataProviderConfig).count() + return Pair(metaDataProviderConfig.hostname(), numberOfEntries) + } + + private suspend fun getNumberOfEntriesAndPercentReviewed(mergedAnime: List<Anime>): Pair<Int, Int> { + val numberOfUnreviewedEntries = mergedAnime + .map { it.sources } + .map { it.filterNot { uri -> uri.host == AnimeCountdownConfig.hostname() }.toHashSet() } + .map { + return@map if (it.size == 1) { + reviewedIsolatedEntriesAccessor.contains(it.first()) || mergeLockAccessor.hasMergeLock(it) + } else { + mergeLockAccessor.hasMergeLock(it) + } + } + .filterNot { it } + .count() + + val reviewedEntriesInPercent = (100.0 - (numberOfUnreviewedEntries.toDouble() / mergedAnime.size.toDouble() * 100.0)).toInt() + + return Pair(mergedAnime.size, reviewedEntriesInPercent) + } + + companion object { + private val log by LoggerDelegate() + + /** + * Opening marker of the generated statistics section within an existing `README.md`. + * @since 1.0.0 + */ + const val STATISTICS_START: String = "<!-- statistics -->" + + /** + * Closing marker of the generated statistics section within an existing `README.md`. + * @since 1.0.0 + */ + const val STATISTICS_END: String = "<!-- /statistics -->" + + /** + * Singleton of [DefaultReadmeCreator] + * @since 1.0.0 + */ + val instance: DefaultReadmeCreator by lazy { DefaultReadmeCreator() } + } +} \ No newline at end of file diff --git a/lib/src/main/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemover.kt b/lib/src/main/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemover.kt index 91622e47..d53917ef 100644 --- a/lib/src/main/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemover.kt +++ b/lib/src/main/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemover.kt @@ -33,7 +33,7 @@ class DefaultAmbiguousSynonymRemover: AmbiguousSynonymRemover { var removed = 0 allEntries.forEachIndexed { index, anime -> - val relatedEntries = anime.relatedAnime.mapNotNull { indexBySource[it] } + val relatedEntries = anime.relatedAnime.mapNotNull { indexBySource[it.uri] } .filterNot { it == index } .distinct() diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt index 312573ff..c45e14cd 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt @@ -1,1254 +1,1311 @@ -package io.github.manamiproject.modb.app - -import io.github.manamiproject.kommand.CommandExecutor -import io.github.manamiproject.kommand.CommandLineConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.convfiles.AlreadyDownloadedIdsFinder -import io.github.manamiproject.modb.app.crawlers.HighestIdDetector -import io.github.manamiproject.modb.app.crawlers.IdRangeSelector -import io.github.manamiproject.modb.app.crawlers.LastPageMemorizer -import io.github.manamiproject.modb.app.crawlers.PaginationIdRangeSelector -import io.github.manamiproject.modb.app.dataset.DatasetFileAccessor -import io.github.manamiproject.modb.app.dataset.DatasetFileType -import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateEntry -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateScheduler -import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor -import io.github.manamiproject.modb.app.merging.lock.MergeLock -import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor -import io.github.manamiproject.modb.app.network.NetworkController -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.ConfigRegistry -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.PathAnimeConverter -import io.github.manamiproject.modb.core.downloader.Downloader -import io.github.manamiproject.modb.core.extensions.Directory -import io.github.manamiproject.modb.core.extensions.RegularFile -import io.github.manamiproject.modb.core.httpclient.HttpClient -import io.github.manamiproject.modb.core.httpclient.HttpResponse -import io.github.manamiproject.modb.core.httpclient.RequestBody -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.anime.MetaDataProviderScoreValue -import io.github.manamiproject.modb.core.anime.NoScore -import io.github.manamiproject.modb.core.anime.ScoreValue -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.OutputKey -import io.github.manamiproject.modb.core.extractor.Selector -import io.github.manamiproject.modb.core.httpclient.RetryCase -import io.github.manamiproject.modb.serde.json.deserializer.Deserializer -import io.github.manamiproject.modb.serde.json.serializer.JsonSerializer -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import kotlinx.coroutines.Deferred -import java.net.URI -import java.net.URL -import java.nio.file.Path -import java.nio.file.WatchEvent -import java.nio.file.WatchKey -import java.nio.file.Watchable -import java.time.Clock -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.util.concurrent.TimeUnit -import java.nio.file.WatchService as JavaWatchService - -internal object TestConfigRegistry: ConfigRegistry { - override fun boolean(key: String): Boolean = shouldNotBeInvoked() - override fun double(key: String): Double = shouldNotBeInvoked() - override fun int(key: String): Int = shouldNotBeInvoked() - override fun <T : Any> list(key: String): List<T> = shouldNotBeInvoked() - override fun localDate(key: String): LocalDate = shouldNotBeInvoked() - override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() - override fun long(key: String): Long = shouldNotBeInvoked() - override fun <T : Any> map(key: String): Map<String, T> = shouldNotBeInvoked() - override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() - override fun string(key: String): String = shouldNotBeInvoked() -} - -internal object TestMetaDataProviderConfig: MetaDataProviderConfig { - override fun hostname() = shouldNotBeInvoked() - override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() - override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() - override fun extractAnimeId(uri: URI): AnimeId = shouldNotBeInvoked() - override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() -} - -internal object TestAppConfig: Config { - override fun isTestContext(): Boolean = true - override fun currentWeekWorkingDir(): Directory = shouldNotBeInvoked() - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() - override fun outputDirectory(): Directory = shouldNotBeInvoked() - override fun downloadControlStateDirectory(): Directory = shouldNotBeInvoked() - override fun downloadsDirectory(): Directory = shouldNotBeInvoked() - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = shouldNotBeInvoked() - override fun clock(): Clock = shouldNotBeInvoked() - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = shouldNotBeInvoked() -} - -internal object TestPathAnimeConverter: PathAnimeConverter { - override suspend fun convert(path: Path): Collection<AnimeRaw> = shouldNotBeInvoked() -} - -internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { - override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() -} - -internal object TestJavaWatchService: JavaWatchService { - override fun close() = shouldNotBeInvoked() - override fun poll(): WatchKey = shouldNotBeInvoked() - override fun poll(timeout: Long, unit: TimeUnit?): WatchKey = shouldNotBeInvoked() - override fun take(): WatchKey = shouldNotBeInvoked() -} - -internal object TestWatchKey: WatchKey { - override fun isValid(): Boolean = shouldNotBeInvoked() - override fun pollEvents(): MutableList<WatchEvent<*>> = shouldNotBeInvoked() - override fun reset(): Boolean = shouldNotBeInvoked() - override fun cancel() = shouldNotBeInvoked() - override fun watchable(): Watchable = shouldNotBeInvoked() -} - -internal object TestDownloadControlStateAccessor: DownloadControlStateAccessor { - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() - override suspend fun allAnime(): List<AnimeRaw> = shouldNotBeInvoked() - override suspend fun allAnime(metaDataProviderConfig: MetaDataProviderConfig): List<AnimeRaw> = shouldNotBeInvoked() - override suspend fun allDcsEntries(): List<DownloadControlStateEntry> = shouldNotBeInvoked() - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = shouldNotBeInvoked() - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = shouldNotBeInvoked() - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry) = shouldNotBeInvoked() - override suspend fun removeDeadEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId) = shouldNotBeInvoked() - override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile = shouldNotBeInvoked() - override suspend fun highestIdAlreadyInDataset(metaDataProviderConfig: MetaDataProviderConfig): Int = shouldNotBeInvoked() -} - -internal object TestDatasetFileAccessor: DatasetFileAccessor { - override suspend fun fetchEntries(): List<Anime> = shouldNotBeInvoked() - override suspend fun saveEntries(anime: List<Anime>) = shouldNotBeInvoked() - override fun offlineDatabaseFile(type: DatasetFileType): RegularFile = shouldNotBeInvoked() -} - -internal object TestMergeLockAccessor: MergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = shouldNotBeInvoked() - override suspend fun isPartOfMergeLock(uri: URI): Boolean = shouldNotBeInvoked() - override suspend fun getMergeLock(uri: URI): MergeLock = shouldNotBeInvoked() - override suspend fun addMergeLock(mergeLock: MergeLock) = shouldNotBeInvoked() - override suspend fun replaceUri(oldUri: URI, newUri: URI) = shouldNotBeInvoked() - override suspend fun removeEntry(uri: URI) = shouldNotBeInvoked() - override suspend fun allSourcesInAllMergeLockEntries(): Set<URI> = shouldNotBeInvoked() -} - -internal class TestJsonSerializer<T>: JsonSerializer<T> { - override suspend fun serialize(obj: T, minify: Boolean): String = shouldNotBeInvoked() -} - -internal object TestDeadEntriesAccessor: DeadEntriesAccessor { - override fun deadEntriesFile(metaDataProviderConfig: MetaDataProviderConfig, type: DatasetFileType): RegularFile = shouldNotBeInvoked() - override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) = shouldNotBeInvoked() - override suspend fun removeDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { } - override suspend fun determineDeadEntries(sources: Collection<URI>): Set<URI> = shouldNotBeInvoked() - override suspend fun fetchDeadEntries(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() -} - -internal object TestReviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor { - override fun contains(uri: URI): Boolean = shouldNotBeInvoked() - override suspend fun addCheckedEntry(uri: URI) = shouldNotBeInvoked() -} - -internal object TestCommandExecutor: CommandExecutor { - override var config: CommandLineConfig - get() = shouldNotBeInvoked() - set(_) = shouldNotBeInvoked() - override fun executeCmd(command: List<String>): String = shouldNotBeInvoked() -} - -internal object TestHttpClient: HttpClient { - override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() -} - -internal object TestNetworkController: NetworkController { - override suspend fun restartAsync(): Deferred<Boolean> = shouldNotBeInvoked() - override fun isNetworkActive(): Boolean = shouldNotBeInvoked() -} - -internal object TestDownloadControlStateScheduler: DownloadControlStateScheduler { - override suspend fun findEntriesNotScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() - override suspend fun findEntriesScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() -} - -internal object TestAlreadyDownloadedIdsFinder: AlreadyDownloadedIdsFinder { - override suspend fun alreadyDownloadedIds(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() -} - -internal object TestDownloader: Downloader { - override suspend fun download(id: AnimeId, onDeadEntry: suspend (AnimeId) -> Unit): String = shouldNotBeInvoked() -} - -internal object TestLastPageMemorizerInt: LastPageMemorizer<Int> { - override suspend fun memorizeLastPage(page: Int) = shouldNotBeInvoked() - override suspend fun retrieveLastPage(): Int = shouldNotBeInvoked() -} - -internal object TestLastPageMemorizerString: LastPageMemorizer<String> { - override suspend fun memorizeLastPage(page: String) = shouldNotBeInvoked() - override suspend fun retrieveLastPage(): String = shouldNotBeInvoked() -} - -internal object TestHighestIdDetector: HighestIdDetector { - override suspend fun detectHighestId(): Int = shouldNotBeInvoked() -} - -internal object TestPaginationIdRangeSelectorInt: PaginationIdRangeSelector<Int> { - override suspend fun idDownloadList(page: Int): List<AnimeId> = shouldNotBeInvoked() -} - -internal object TestPaginationIdRangeSelectorString: PaginationIdRangeSelector<String> { - override suspend fun idDownloadList(page: String): List<AnimeId> = shouldNotBeInvoked() -} - -internal object TestDataExtractor: DataExtractor { - override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() -} - -internal object TestIdRangeSelectorInt: IdRangeSelector<Int> { - override suspend fun idDownloadList(): List<Int> = shouldNotBeInvoked() -} - -@Suppress("unused") -internal object TestAnimeRawObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: AnimeRaw - get() = AnimeRaw("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - _synonyms = hashSetOf( - "DN", - "デスノート", - ), - _studios = hashSetOf( - "madhouse", - ), - _producers = hashSetOf( - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - _tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":["https://myanimelist.net/anime/2994"],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Go-toubun no Hanayome *", - _sources = hashSetOf( - URI("https://anilist.co/anime/177191"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/131520"), - ), - _tags = hashSetOf( - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anilist.co/anime/177191" - ], - "title": "Go-toubun no Hanayome *", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - } - ], - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://anilist.co/anime/131520" - ], - "tags": [ - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://anilist.co/anime/131520"],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "5-toubun no Hanayome*", - _sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - URI("https://anidb.net/anime/16165"), - URI("https://anilist.co/anime/131520"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie"), - URI("https://animecountdown.com/1577789"), - URI("https://anisearch.com/anime/16091"), - URI("https://kitsu.app/anime/44229"), - URI("https://livechart.me/anime/10488"), - URI("https://livechart.me/anime/11921"), - URI("https://livechart.me/anime/3448"), - URI("https://livechart.me/anime/9428"), - URI("https://myanimelist.net/anime/48548"), - URI("https://simkl.com/anime/1577789"), - ), - _tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anidb.net", - value = 6.63, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "anime-planet.com", - value = 3.845, - range = 0.5..5.0, - ), - MetaDataProviderScoreValue( - hostname = "anisearch.com", - value = 3.9, - range = 0.1..5.0, - ), - MetaDataProviderScoreValue( - hostname = "kitsu.app", - value = 76.95, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "livechart.me", - value = 8.35, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "simkl.com", - value = 7.9, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anidb.net", - "value": 6.63, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "anime-planet.com", - "value": 3.845, - "range": { - "minInclusive": 0.5, - "maxInclusive": 5.0 - } - }, - { - "hostname": "anisearch.com", - "value": 3.9, - "range": { - "minInclusive": 0.1, - "maxInclusive": 5.0 - } - }, - { - "hostname": "kitsu.app", - "value": 76.95, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "livechart.me", - "value": 8.35, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "simkl.com", - "value": 7.9, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://anidb.net/anime/16165", - "https://anilist.co/anime/131520", - "https://anime-planet.com/anime/the-quintessential-quintuplets-movie", - "https://animecountdown.com/1577789", - "https://anisearch.com/anime/16091", - "https://kitsu.app/anime/44229", - "https://livechart.me/anime/10488", - "https://livechart.me/anime/11921", - "https://livechart.me/anime/3448", - "https://livechart.me/anime/9428", - "https://myanimelist.net/anime/48548", - "https://simkl.com/anime/1577789" - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://anidb.net/anime/16165","https://anilist.co/anime/131520","https://anime-planet.com/anime/the-quintessential-quintuplets-movie","https://animecountdown.com/1577789","https://anisearch.com/anime/16091","https://kitsu.app/anime/44229","https://livechart.me/anime/10488","https://livechart.me/anime/11921","https://livechart.me/anime/3448","https://livechart.me/anime/9428","https://myanimelist.net/anime/48548","https://simkl.com/anime/1577789"],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -@Suppress("unused") -internal object TestAnimeObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: Anime - get() = Anime("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "score": null, - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: Anime - get() = Anime( - title = "Death Note", - sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - score = NoScore, - synonyms = hashSetOf( - "DN", - "デスノート", - ), - studios = hashSetOf( - "madhouse", - ), - producers = hashSetOf( - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap", - ), - relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "score": null, - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":["https://myanimelist.net/anime/2994"],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://myanimelist.net/anime/58755"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.44, - arithmeticMean = 7.44, - median = 7.44, - ), - synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/48548"), - ), - tags = hashSetOf( - "comedy", - "harem", - "romance", - "school", - "shounen", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/58755" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.44, - "arithmeticMean": 7.44, - "median": 7.44 - }, - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/48548" - ], - "tags": [ - "comedy", - "harem", - "romance", - "school", - "shounen" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://myanimelist.net/anime/48548"],"tags":["comedy","harem","romance","school","shounen"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.616175305477198, - arithmeticMean = 7.624601113172541, - median = 7.7272727272727275, - ), - synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - "tbs", - ), - relatedAnime = hashSetOf( - URI("https://anidb.net/anime/16165"), - URI("https://anilist.co/anime/131520"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie"), - URI("https://animecountdown.com/1577789"), - URI("https://anisearch.com/anime/16091"), - URI("https://kitsu.app/anime/44229"), - URI("https://livechart.me/anime/10488"), - URI("https://livechart.me/anime/11921"), - URI("https://livechart.me/anime/3448"), - URI("https://livechart.me/anime/9428"), - URI("https://myanimelist.net/anime/48548"), - URI("https://simkl.com/anime/1577789"), - ), - tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ) - - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.616175305477198, - "arithmeticMean": 7.624601113172541, - "median": 7.7272727272727275 - }, - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon", - "tbs" - ], - "relatedAnime": [ - "https://anidb.net/anime/16165", - "https://anilist.co/anime/131520", - "https://anime-planet.com/anime/the-quintessential-quintuplets-movie", - "https://animecountdown.com/1577789", - "https://anisearch.com/anime/16091", - "https://kitsu.app/anime/44229", - "https://livechart.me/anime/10488", - "https://livechart.me/anime/11921", - "https://livechart.me/anime/3448", - "https://livechart.me/anime/9428", - "https://myanimelist.net/anime/48548", - "https://simkl.com/anime/1577789" - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":["https://anidb.net/anime/16165","https://anilist.co/anime/131520","https://anime-planet.com/anime/the-quintessential-quintuplets-movie","https://animecountdown.com/1577789","https://anisearch.com/anime/16091","https://kitsu.app/anime/44229","https://livechart.me/anime/10488","https://livechart.me/anime/11921","https://livechart.me/anime/3448","https://livechart.me/anime/9428","https://myanimelist.net/anime/48548","https://simkl.com/anime/1577789"],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} \ No newline at end of file +package io.github.manamiproject.modb.app + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.kommand.CommandExecutor +import io.github.manamiproject.kommand.CommandLineConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.convfiles.AlreadyDownloadedIdsFinder +import io.github.manamiproject.modb.app.crawlers.HighestIdDetector +import io.github.manamiproject.modb.app.crawlers.IdRangeSelector +import io.github.manamiproject.modb.app.crawlers.LastPageMemorizer +import io.github.manamiproject.modb.app.crawlers.PaginationIdRangeSelector +import io.github.manamiproject.modb.app.dataset.DatasetFileAccessor +import io.github.manamiproject.modb.app.dataset.DatasetFileType +import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateEntry +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateScheduler +import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor +import io.github.manamiproject.modb.app.merging.lock.MergeLock +import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor +import io.github.manamiproject.modb.app.network.NetworkController +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.ConfigRegistry +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.PathAnimeConverter +import io.github.manamiproject.modb.core.downloader.Downloader +import io.github.manamiproject.modb.core.extensions.Directory +import io.github.manamiproject.modb.core.extensions.RegularFile +import io.github.manamiproject.modb.core.httpclient.HttpClient +import io.github.manamiproject.modb.core.httpclient.HttpResponse +import io.github.manamiproject.modb.core.httpclient.RequestBody +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.anime.MetaDataProviderScoreValue +import io.github.manamiproject.modb.core.anime.NoScore +import io.github.manamiproject.modb.core.anime.ScoreValue +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.OutputKey +import io.github.manamiproject.modb.core.extractor.Selector +import io.github.manamiproject.modb.core.httpclient.RetryCase +import io.github.manamiproject.modb.serde.json.deserializer.Deserializer +import io.github.manamiproject.modb.serde.json.serializer.JsonSerializer +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import kotlinx.coroutines.Deferred +import java.net.URI +import java.net.URL +import java.nio.file.Path +import java.nio.file.WatchEvent +import java.nio.file.WatchKey +import java.nio.file.Watchable +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.util.concurrent.TimeUnit +import java.nio.file.WatchService as JavaWatchService + +internal object TestConfigRegistry: ConfigRegistry { + override fun boolean(key: String): Boolean = shouldNotBeInvoked() + override fun double(key: String): Double = shouldNotBeInvoked() + override fun int(key: String): Int = shouldNotBeInvoked() + override fun <T : Any> list(key: String): List<T> = shouldNotBeInvoked() + override fun localDate(key: String): LocalDate = shouldNotBeInvoked() + override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() + override fun long(key: String): Long = shouldNotBeInvoked() + override fun <T : Any> map(key: String): Map<String, T> = shouldNotBeInvoked() + override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() + override fun string(key: String): String = shouldNotBeInvoked() +} + +internal object TestMetaDataProviderConfig: MetaDataProviderConfig { + override fun hostname() = shouldNotBeInvoked() + override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() + override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() + override fun extractAnimeId(uri: URI): AnimeId = shouldNotBeInvoked() + override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() +} + +internal object TestAppConfig: Config { + override fun isTestContext(): Boolean = true + override fun currentWeekWorkingDir(): Directory = shouldNotBeInvoked() + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() + override fun outputDirectory(): Directory = shouldNotBeInvoked() + override fun downloadControlStateDirectory(): Directory = shouldNotBeInvoked() + override fun downloadsDirectory(): Directory = shouldNotBeInvoked() + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = shouldNotBeInvoked() + override fun clock(): Clock = shouldNotBeInvoked() + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = shouldNotBeInvoked() +} + +internal object TestPathAnimeConverter: PathAnimeConverter { + override suspend fun convert(path: Path): Collection<AnimeRaw> = shouldNotBeInvoked() +} + +internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { + override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() +} + +internal object TestJavaWatchService: JavaWatchService { + override fun close() = shouldNotBeInvoked() + override fun poll(): WatchKey = shouldNotBeInvoked() + override fun poll(timeout: Long, unit: TimeUnit?): WatchKey = shouldNotBeInvoked() + override fun take(): WatchKey = shouldNotBeInvoked() +} + +internal object TestWatchKey: WatchKey { + override fun isValid(): Boolean = shouldNotBeInvoked() + override fun pollEvents(): MutableList<WatchEvent<*>> = shouldNotBeInvoked() + override fun reset(): Boolean = shouldNotBeInvoked() + override fun cancel() = shouldNotBeInvoked() + override fun watchable(): Watchable = shouldNotBeInvoked() +} + +internal object TestDownloadControlStateAccessor: DownloadControlStateAccessor { + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() + override suspend fun allAnime(): List<AnimeRaw> = shouldNotBeInvoked() + override suspend fun allAnime(metaDataProviderConfig: MetaDataProviderConfig): List<AnimeRaw> = shouldNotBeInvoked() + override suspend fun allDcsEntries(): List<DownloadControlStateEntry> = shouldNotBeInvoked() + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = shouldNotBeInvoked() + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = shouldNotBeInvoked() + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry) = shouldNotBeInvoked() + override suspend fun removeDeadEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId) = shouldNotBeInvoked() + override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile = shouldNotBeInvoked() + override suspend fun highestIdAlreadyInDataset(metaDataProviderConfig: MetaDataProviderConfig): Int = shouldNotBeInvoked() +} + +internal object TestDatasetFileAccessor: DatasetFileAccessor { + override suspend fun fetchEntries(): List<Anime> = shouldNotBeInvoked() + override suspend fun saveEntries(anime: List<Anime>) = shouldNotBeInvoked() + override fun offlineDatabaseFile(type: DatasetFileType): RegularFile = shouldNotBeInvoked() +} + +internal object TestMergeLockAccessor: MergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = shouldNotBeInvoked() + override suspend fun isPartOfMergeLock(uri: URI): Boolean = shouldNotBeInvoked() + override suspend fun getMergeLock(uri: URI): MergeLock = shouldNotBeInvoked() + override suspend fun addMergeLock(mergeLock: MergeLock) = shouldNotBeInvoked() + override suspend fun replaceUri(oldUri: URI, newUri: URI) = shouldNotBeInvoked() + override suspend fun removeEntry(uri: URI) = shouldNotBeInvoked() + override suspend fun allSourcesInAllMergeLockEntries(): Set<URI> = shouldNotBeInvoked() +} + +internal class TestJsonSerializer<T>: JsonSerializer<T> { + override suspend fun serialize(obj: T, minify: Boolean): String = shouldNotBeInvoked() +} + +internal object TestDeadEntriesAccessor: DeadEntriesAccessor { + override fun deadEntriesFile(metaDataProviderConfig: MetaDataProviderConfig, type: DatasetFileType): RegularFile = shouldNotBeInvoked() + override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) = shouldNotBeInvoked() + override suspend fun removeDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { } + override suspend fun determineDeadEntries(sources: Collection<URI>): Set<URI> = shouldNotBeInvoked() + override suspend fun fetchDeadEntries(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() +} + +internal object TestReviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor { + override fun contains(uri: URI): Boolean = shouldNotBeInvoked() + override suspend fun addCheckedEntry(uri: URI) = shouldNotBeInvoked() +} + +internal object TestCommandExecutor: CommandExecutor { + override var config: CommandLineConfig + get() = shouldNotBeInvoked() + set(_) = shouldNotBeInvoked() + override fun executeCmd(command: List<String>): String = shouldNotBeInvoked() +} + +internal object TestHttpClient: HttpClient { + override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() +} + +internal object TestNetworkController: NetworkController { + override suspend fun restartAsync(): Deferred<Boolean> = shouldNotBeInvoked() + override fun isNetworkActive(): Boolean = shouldNotBeInvoked() +} + +internal object TestDownloadControlStateScheduler: DownloadControlStateScheduler { + override suspend fun findEntriesNotScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() + override suspend fun findEntriesScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() +} + +internal object TestAlreadyDownloadedIdsFinder: AlreadyDownloadedIdsFinder { + override suspend fun alreadyDownloadedIds(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() +} + +internal object TestDownloader: Downloader { + override suspend fun download(id: AnimeId, onDeadEntry: suspend (AnimeId) -> Unit): String = shouldNotBeInvoked() +} + +internal object TestLastPageMemorizerInt: LastPageMemorizer<Int> { + override suspend fun memorizeLastPage(page: Int) = shouldNotBeInvoked() + override suspend fun retrieveLastPage(): Int = shouldNotBeInvoked() +} + +internal object TestLastPageMemorizerString: LastPageMemorizer<String> { + override suspend fun memorizeLastPage(page: String) = shouldNotBeInvoked() + override suspend fun retrieveLastPage(): String = shouldNotBeInvoked() +} + +internal object TestHighestIdDetector: HighestIdDetector { + override suspend fun detectHighestId(): Int = shouldNotBeInvoked() +} + +internal object TestPaginationIdRangeSelectorInt: PaginationIdRangeSelector<Int> { + override suspend fun idDownloadList(page: Int): List<AnimeId> = shouldNotBeInvoked() +} + +internal object TestPaginationIdRangeSelectorString: PaginationIdRangeSelector<String> { + override suspend fun idDownloadList(page: String): List<AnimeId> = shouldNotBeInvoked() +} + +internal object TestDataExtractor: DataExtractor { + override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() +} + +internal object TestIdRangeSelectorInt: IdRangeSelector<Int> { + override suspend fun idDownloadList(): List<Int> = shouldNotBeInvoked() +} + +@Suppress("unused") +internal object TestAnimeRawObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: AnimeRaw + get() = AnimeRaw("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + _synonyms = hashSetOf( + "DN", + "デスノート", + ), + _studios = hashSetOf( + "madhouse", + ), + _producers = hashSetOf( + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + _tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Go-toubun no Hanayome *", + _sources = hashSetOf( + URI("https://anilist.co/anime/177191"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/131520")), + ), + _tags = hashSetOf( + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anilist.co/anime/177191" + ], + "title": "Go-toubun no Hanayome *", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + } + ], + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anilist.co/anime/131520" + } + ], + "tags": [ + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "5-toubun no Hanayome*", + _sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + _tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anidb.net", + value = 6.63, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "anime-planet.com", + value = 3.845, + range = 0.5..5.0, + ), + MetaDataProviderScoreValue( + hostname = "anisearch.com", + value = 3.9, + range = 0.1..5.0, + ), + MetaDataProviderScoreValue( + hostname = "kitsu.app", + value = 76.95, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "livechart.me", + value = 8.35, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "simkl.com", + value = 7.9, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anidb.net", + "value": 6.63, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "anime-planet.com", + "value": 3.845, + "range": { + "minInclusive": 0.5, + "maxInclusive": 5.0 + } + }, + { + "hostname": "anisearch.com", + "value": 3.9, + "range": { + "minInclusive": 0.1, + "maxInclusive": 5.0 + } + }, + { + "hostname": "kitsu.app", + "value": 76.95, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "livechart.me", + "value": 8.35, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "simkl.com", + "value": 7.9, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +@Suppress("unused") +internal object TestAnimeObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: Anime + get() = Anime("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "score": null, + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: Anime + get() = Anime( + title = "Death Note", + sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + score = NoScore, + synonyms = hashSetOf( + "DN", + "デスノート", + ), + studios = hashSetOf( + "madhouse", + ), + producers = hashSetOf( + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "score": null, + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://myanimelist.net/anime/58755"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.44, + arithmeticMean = 7.44, + median = 7.44, + ), + synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + ), + tags = hashSetOf( + "comedy", + "harem", + "romance", + "school", + "shounen", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/58755" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.44, + "arithmeticMean": 7.44, + "median": 7.44 + }, + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/48548" + } + ], + "tags": [ + "comedy", + "harem", + "romance", + "school", + "shounen" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.616175305477198, + arithmeticMean = 7.624601113172541, + median = 7.7272727272727275, + ), + synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + "tbs", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ) + + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.616175305477198, + "arithmeticMean": 7.624601113172541, + "median": 7.7272727272727275 + }, + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon", + "tbs" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt index 84b337c4..fbd02833 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt @@ -1,98 +1,124 @@ -package io.github.manamiproject.modb.app.dataset - -import io.github.manamiproject.modb.anilist.AnilistConfig -import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.myanimelist.MyanimelistConfig -import io.github.manamiproject.modb.simkl.SimklConfig -import org.assertj.core.api.Assertions.assertThat -import kotlin.test.Test - -internal class AnimeCountdownUrlAdderTest { - - @Test - fun `correctly adds URI in sources`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf( - SimklConfig.buildAnimeLink("1535"), - ), - ) - - val expectedAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf( - AnimeCountdownConfig.buildAnimeLink("1535"), - SimklConfig.buildAnimeLink("1535"), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(expectedAnime) - } - - @Test - fun `correctly adds URI in relatedAnime`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - SimklConfig.buildAnimeLink("1535"), - ), - ) - - val expectedAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - AnimeCountdownConfig.buildAnimeLink("1535"), - SimklConfig.buildAnimeLink("1535"), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(expectedAnime) - } - - @Test - fun `leaves sources property of anime without simkl source unaffected`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf( - MyanimelistConfig.buildAnimeLink("1535"), - AnilistConfig.buildAnimeLink("1535"), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(testAnime) - } - - @Test - fun `leaves relatedAnime property of anime without simkl source unaffected`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - MyanimelistConfig.buildAnimeLink("1535"), - AnilistConfig.buildAnimeLink("1535"), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(testAnime) - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.dataset + +import io.github.manamiproject.modb.anilist.AnilistConfig +import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.myanimelist.MyanimelistConfig +import io.github.manamiproject.modb.simkl.SimklConfig +import org.assertj.core.api.Assertions.assertThat +import kotlin.test.Test + +internal class AnimeCountdownUrlAdderTest { + + @Test + fun `correctly adds URI in sources`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf( + SimklConfig.buildAnimeLink("1535"), + ), + ) + + val expectedAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf( + AnimeCountdownConfig.buildAnimeLink("1535"), + SimklConfig.buildAnimeLink("1535"), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(expectedAnime) + } + + @Test + fun `correctly adds URI in relatedAnime`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(SimklConfig.buildAnimeLink("1535")), + ), + ) + + val expectedAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(AnimeCountdownConfig.buildAnimeLink("1535")), + RelatedAnime(SimklConfig.buildAnimeLink("1535")), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(expectedAnime) + } + + @Test + fun `keeps the relation when adding URI in relatedAnime`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(SimklConfig.buildAnimeLink("1535"), "Sequel"), + ), + ) + + val expectedAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(AnimeCountdownConfig.buildAnimeLink("1535"), "Sequel"), + RelatedAnime(SimklConfig.buildAnimeLink("1535"), "Sequel"), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(expectedAnime) + } + + @Test + fun `leaves sources property of anime without simkl source unaffected`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf( + MyanimelistConfig.buildAnimeLink("1535"), + AnilistConfig.buildAnimeLink("1535"), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(testAnime) + } + + @Test + fun `leaves relatedAnime property of anime without simkl source unaffected`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(MyanimelistConfig.buildAnimeLink("1535")), + RelatedAnime(AnilistConfig.buildAnimeLink("1535")), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(testAnime) + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt index 6e34d5c0..ab55bc14 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt @@ -1,322 +1,323 @@ -package io.github.manamiproject.modb.app.downloadcontrolstate - -import io.github.manamiproject.modb.anilist.AnilistConfig -import io.github.manamiproject.modb.app.TestAppConfig -import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.date.WeekOfYear -import io.github.manamiproject.modb.kitsu.KitsuConfig -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test - -internal class DefaultDownloadControlStateSchedulerTest { - - @Nested - inner class FindEntriesNotScheduledForCurrentWeekTests { - - @Test - fun `same year`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2021, 45), - _anime = AnimeRaw( - _title = "Fruits Basket", - type = TV, - episodes = 26, - picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), - thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), - status = FINISHED, - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 2001, - ), - _sources = hashSetOf(URI("https://kitsu.app/anime/99")), - _synonyms = hashSetOf( - "Furuba", - "フルーツバスケット", - ), - _relatedAnime = hashSetOf( - URI("https://kitsu.app/anime/41995"), - ), - ), - ) - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 2), - _nextDownload = WeekOfYear(2021, 4), - _anime = AnimeRaw( - _title = "Unexpected", - _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), - ), - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("99") - } - } - - @Test - fun `next yeart`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2022, 2), - _anime = AnimeRaw( - _title = "Fruits Basket", - type = TV, - episodes = 26, - picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), - thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), - status = FINISHED, - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 2001, - ), - _sources = hashSetOf(URI("https://kitsu.app/anime/99")), - _synonyms = hashSetOf( - "Furuba", - "フルーツバスケット", - ), - _relatedAnime = hashSetOf( - URI("https://kitsu.app/anime/41995"), - ), - ), - ) - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 2), - _nextDownload = WeekOfYear(2021, 4), - _anime = AnimeRaw( - _title = "Unexpected", - _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), - ), - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("99") - } - } - } - - @Nested - inner class FindEntriesScheduledForCurrentWeekTests { - - @Test - fun `same year`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 2), - _nextDownload = WeekOfYear(2021, 4), - _anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/30"), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - ) - - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2021, 45), - _anime = AnimeRaw( - _title = "Unexpected Entry", - _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), - ) - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("32") - } - } - - @Test - fun `previous year`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2020, 49), - _nextDownload = WeekOfYear(2020, 51), - _anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/30"), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - ) - - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2021, 45), - _anime = AnimeRaw( - _title = "Unexpected Entry", - _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), - ) - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("32") - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultDownloadControlStateScheduler.instance - - // when - val result = DefaultDownloadControlStateScheduler.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateScheduler::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.downloadcontrolstate + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.anilist.AnilistConfig +import io.github.manamiproject.modb.app.TestAppConfig +import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.date.WeekOfYear +import io.github.manamiproject.modb.kitsu.KitsuConfig +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test + +internal class DefaultDownloadControlStateSchedulerTest { + + @Nested + inner class FindEntriesNotScheduledForCurrentWeekTests { + + @Test + fun `same year`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2021, 45), + _anime = AnimeRaw( + _title = "Fruits Basket", + type = TV, + episodes = 26, + picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), + thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), + status = FINISHED, + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 2001, + ), + _sources = hashSetOf(URI("https://kitsu.app/anime/99")), + _synonyms = hashSetOf( + "Furuba", + "フルーツバスケット", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://kitsu.app/anime/41995")), + ), + ), + ) + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 2), + _nextDownload = WeekOfYear(2021, 4), + _anime = AnimeRaw( + _title = "Unexpected", + _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), + ), + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("99") + } + } + + @Test + fun `next yeart`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2022, 2), + _anime = AnimeRaw( + _title = "Fruits Basket", + type = TV, + episodes = 26, + picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), + thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), + status = FINISHED, + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 2001, + ), + _sources = hashSetOf(URI("https://kitsu.app/anime/99")), + _synonyms = hashSetOf( + "Furuba", + "フルーツバスケット", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://kitsu.app/anime/41995")), + ), + ), + ) + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 2), + _nextDownload = WeekOfYear(2021, 4), + _anime = AnimeRaw( + _title = "Unexpected", + _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), + ), + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("99") + } + } + } + + @Nested + inner class FindEntriesScheduledForCurrentWeekTests { + + @Test + fun `same year`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 2), + _nextDownload = WeekOfYear(2021, 4), + _anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + ) + + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2021, 45), + _anime = AnimeRaw( + _title = "Unexpected Entry", + _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), + ) + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("32") + } + } + + @Test + fun `previous year`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2020, 49), + _nextDownload = WeekOfYear(2020, 51), + _anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + ) + + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2021, 45), + _anime = AnimeRaw( + _title = "Unexpected Entry", + _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), + ) + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("32") + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultDownloadControlStateScheduler.instance + + // when + val result = DefaultDownloadControlStateScheduler.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateScheduler::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt index 51db9fe6..de4cf90e 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt @@ -1,942 +1,942 @@ -package io.github.manamiproject.modb.app.downloadcontrolstate - -import io.github.manamiproject.modb.anilist.AnilistConfig -import io.github.manamiproject.modb.app.TestAppConfig -import io.github.manamiproject.modb.app.TestConfigRegistry -import io.github.manamiproject.modb.app.TestDeadEntriesAccessor -import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor -import io.github.manamiproject.modb.app.TestMetaDataProviderConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.convfiles.CONVERTED_FILE_SUFFIX -import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor -import io.github.manamiproject.modb.app.minusWeeks -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.extensions.Directory -import io.github.manamiproject.modb.core.extensions.RegularFile -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.core.config.ConfigRegistry -import io.github.manamiproject.modb.core.json.Json -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.date.WeekOfYear -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneId -import kotlin.io.path.createDirectory -import kotlin.io.path.createFile -import kotlin.test.Test - -internal class DefaultDownloadControlStateUpdaterTest { - - @Nested - inner class UpdateAllTests { - - @Nested - inner class CheckForExtractionProblemsTests { - - @Test - fun `don't trigger anything if there is no conv file`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - } - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - } - } - - @Test - fun `don't throw an exception if the threshold hasn't been reached`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("100")) - ) - val rawFile = rawdata.resolve("100.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("100")) - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val dcsFile = dcs.resolve(testConfig.hostname()).createDirectory().resolve("100.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - } - } - - @Test - fun `triggers alarm at 50 percent differences - score summed up by changes in files`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - // Unchanged anime - val unchangedAnimeId = "100" - val unchangedAnime = AnimeRaw( - _title = "Unchanged anime", - _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)) - ) - val rawFileUnchangedAnime = rawdata.resolve("$unchangedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(unchangedAnime).writeToFile(rawFileUnchangedAnime) - - val dcsUnchangedAnime = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Unchanged anime", - _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)), - ), - ) - val dcsFileUnchangedAnime = metaDataProviderDcsDir.resolve("$unchangedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(dcsUnchangedAnime).writeToFile(dcsFileUnchangedAnime) - - // Changed anime - val changedAnimeId = "200" - val changedAnime = AnimeRaw( - _title = "Changed Anime", - _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)) - ) - val rawFileChangedAnime = rawdata.resolve("$changedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(changedAnime).writeToFile(rawFileChangedAnime) - - val dcsChangedAnime = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Changed Anime", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)), - ), - ) - val dcsFileChangedAnime = metaDataProviderDcsDir.resolve("$changedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(dcsChangedAnime).writeToFile(dcsFileChangedAnime) - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = when (animeId) { - "100" -> dcsUnchangedAnime - "200" -> dcsChangedAnime - else -> shouldNotBeInvoked() - } - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = exceptionExpected<IllegalStateException> { - downloadControlStateUpdater.updateAll() - } - - // then - assertThat(result).hasMessage(""" - Possibly found a problem in the extraction of data: - * example.org with a percentage of 50 - """.trimIndent()) - } - } - } - - @Nested - inner class UpdateChangedIdsTests { - - @Test - fun `treats the old id as dead and keeps the anime under the new id if the provider doesn't support changing ids`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - override fun fileSuffix(): FileSuffix = "html" - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) - ) - val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - rawdata.resolve("previous-test-id.html").createFile() - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - val updated = mutableListOf<AnimeId>() - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - updated.add(animeId) - return true - } - } - - val deadEntries = mutableListOf<AnimeId>() - val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { - override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { - deadEntries.add(animeId) - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - deadEntriesAccessor = testDeadEntriesAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(deadEntries).containsExactly("previous-test-id") - assertThat(updated).containsExactly("new-test-id") - assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() - assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() - assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() - } - } - - @Test - fun `treats the old id as dead and drops the duplicate if the new id was fetched in the same run`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - override fun fileSuffix(): FileSuffix = "html" - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) - ) - val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - rawdata.resolve("previous-test-id.html").createFile() - val newRawFile = rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(newRawFile) - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - val updated = mutableListOf<AnimeId>() - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - updated.add(animeId) - return true - } - } - - val deadEntries = mutableListOf<AnimeId>() - val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { - override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { - deadEntries.add(animeId) - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - deadEntriesAccessor = testDeadEntriesAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(deadEntries).containsExactly("previous-test-id") - assertThat(updated).containsExactly("new-test-id") - assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() - assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() - assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() - } - } - - @Test - fun `don't throw an exception if the threshold hasn't been reached`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) - ) - val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true - } - - var updaterHasBeenExecuted = false - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false - override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { - updaterHasBeenExecuted = true - return dcsFile.parent.resolve("new-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX") - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(updaterHasBeenExecuted).isTrue() - } - } - } - - @Test - fun `creates dcs file if it doesn't exist`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/30"), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - - val expectedDcsEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek(), - _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), - _anime = anime, - ) - - val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(anime).writeToFile(file) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - } - - var receivedDcsEntry: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - receivedDcsEntry = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(receivedDcsEntry).isEqualTo(expectedDcsEntry) - } - } - - @Test - fun `update existing dcs file`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val updatedAnime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/30"), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - - val updatedDcsEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek(), - _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), - _anime = updatedAnime, - ) - - val initialDcsEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(5), - _nextDownload = WeekOfYear.currentWeek(), - _anime = updatedAnime.copy( - duration = Duration.UNKNOWN, - animeSeason = AnimeSeason( - year = AnimeSeason.UNKNOWN_YEAR, - ), - _tags = hashSetOf( - "action", - "mecha", - "sci-fi", - ), - ), - ) - - val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(file) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - } - - var receivedDcsEntry: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = initialDcsEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - receivedDcsEntry = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(receivedDcsEntry).isEqualTo(updatedDcsEntry) - } - } - - @Test - fun `can handle same entries with different IDs - the case in which the anime id has changed`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/30"), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - - val file1 = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(anime).writeToFile(file1) - - val file2 = rawdata.resolve("64.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(anime).writeToFile(file2) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - } - - var invocations = 0 - var hasBeenInvoked = false - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { - hasBeenInvoked = true - return file1 - } - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - invocations++ - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(invocations).isEqualTo(2) - assertThat(hasBeenInvoked).isTrue() - } - } - - @Nested - inner class RescheduleEntriesWhichHaveNotBeenFetchedTests { - - @Test - fun `moves an entry which was due this week, but never fetched, to the following week`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val stranded = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://anilist.co/anime/1535")), - ), - ) - - var received: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(stranded) - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - received = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(received?.lastDownloaded).isEqualTo(stranded.lastDownloaded) - assertThat(received?.weeksWihoutChange).isEqualTo(stranded.weeksWihoutChange) - } - } - - @Test - fun `moves an entry which was due in an earlier week, but never fetched, to the following week`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val overdue = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().plusWeeks(-4), - _nextDownload = WeekOfYear.currentWeek().plusWeeks(-2), - _anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://anilist.co/anime/1535")), - ), - ) - - var received: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(overdue) - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - received = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(received?.lastDownloaded).isEqualTo(overdue.lastDownloaded) - assertThat(received?.weeksWihoutChange).isEqualTo(overdue.weeksWihoutChange) - } - } - - @Test - fun `leaves an entry alone if it has been fetched`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://anilist.co/anime/1535")), - ) - Json.toJson(anime).writeToFile(rawdata.resolve("1535.$CONVERTED_FILE_SUFFIX").createFile()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val fetched = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek(), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - val receivedWeeks = mutableListOf<WeekOfYear>() - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(fetched) - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - receivedWeeks.add(downloadControlStateEntry.nextDownload) - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(receivedWeeks).hasSize(1) - } - } - - @Test - fun `does not reschedule anything for a crawler which has been switched off`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val testConfigRegistry = object: ConfigRegistry by TestConfigRegistry { - @Suppress("UNCHECKED_CAST") - override fun <T : Any> list(key: String): List<T>? = when (key) { - "modb.app.disabledCrawlers" -> listOf(AnilistConfig.hostname()) as List<T> - else -> null - } - override fun int(key: String): Int? = null - } - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = shouldNotBeInvoked() - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - configRegistry = testConfigRegistry, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - // shouldNotBeInvoked in the accessor is the assertion - } - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultDownloadControlStateUpdater.instance - - // when - val result = DefaultDownloadControlStateUpdater.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateUpdater::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.downloadcontrolstate + +import io.github.manamiproject.modb.anilist.AnilistConfig +import io.github.manamiproject.modb.app.TestAppConfig +import io.github.manamiproject.modb.app.TestConfigRegistry +import io.github.manamiproject.modb.app.TestDeadEntriesAccessor +import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor +import io.github.manamiproject.modb.app.TestMetaDataProviderConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.convfiles.CONVERTED_FILE_SUFFIX +import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor +import io.github.manamiproject.modb.app.minusWeeks +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.extensions.Directory +import io.github.manamiproject.modb.core.extensions.RegularFile +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.core.config.ConfigRegistry +import io.github.manamiproject.modb.core.json.Json +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.date.WeekOfYear +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import kotlin.io.path.createDirectory +import kotlin.io.path.createFile +import kotlin.test.Test + +internal class DefaultDownloadControlStateUpdaterTest { + + @Nested + inner class UpdateAllTests { + + @Nested + inner class CheckForExtractionProblemsTests { + + @Test + fun `don't trigger anything if there is no conv file`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + } + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + } + } + + @Test + fun `don't throw an exception if the threshold hasn't been reached`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("100")) + ) + val rawFile = rawdata.resolve("100.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("100")) + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val dcsFile = dcs.resolve(testConfig.hostname()).createDirectory().resolve("100.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + } + } + + @Test + fun `triggers alarm at 50 percent differences - score summed up by changes in files`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + // Unchanged anime + val unchangedAnimeId = "100" + val unchangedAnime = AnimeRaw( + _title = "Unchanged anime", + _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)) + ) + val rawFileUnchangedAnime = rawdata.resolve("$unchangedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(unchangedAnime).writeToFile(rawFileUnchangedAnime) + + val dcsUnchangedAnime = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Unchanged anime", + _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)), + ), + ) + val dcsFileUnchangedAnime = metaDataProviderDcsDir.resolve("$unchangedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(dcsUnchangedAnime).writeToFile(dcsFileUnchangedAnime) + + // Changed anime + val changedAnimeId = "200" + val changedAnime = AnimeRaw( + _title = "Changed Anime", + _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)) + ) + val rawFileChangedAnime = rawdata.resolve("$changedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(changedAnime).writeToFile(rawFileChangedAnime) + + val dcsChangedAnime = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Changed Anime", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)), + ), + ) + val dcsFileChangedAnime = metaDataProviderDcsDir.resolve("$changedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(dcsChangedAnime).writeToFile(dcsFileChangedAnime) + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = when (animeId) { + "100" -> dcsUnchangedAnime + "200" -> dcsChangedAnime + else -> shouldNotBeInvoked() + } + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = exceptionExpected<IllegalStateException> { + downloadControlStateUpdater.updateAll() + } + + // then + assertThat(result).hasMessage(""" + Possibly found a problem in the extraction of data: + * example.org with a percentage of 50 + """.trimIndent()) + } + } + } + + @Nested + inner class UpdateChangedIdsTests { + + @Test + fun `treats the old id as dead and keeps the anime under the new id if the provider doesn't support changing ids`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + override fun fileSuffix(): FileSuffix = "html" + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) + ) + val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + rawdata.resolve("previous-test-id.html").createFile() + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + val updated = mutableListOf<AnimeId>() + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + updated.add(animeId) + return true + } + } + + val deadEntries = mutableListOf<AnimeId>() + val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { + override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { + deadEntries.add(animeId) + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + deadEntriesAccessor = testDeadEntriesAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(deadEntries).containsExactly("previous-test-id") + assertThat(updated).containsExactly("new-test-id") + assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() + assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() + assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() + } + } + + @Test + fun `treats the old id as dead and drops the duplicate if the new id was fetched in the same run`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + override fun fileSuffix(): FileSuffix = "html" + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) + ) + val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + rawdata.resolve("previous-test-id.html").createFile() + val newRawFile = rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(newRawFile) + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + val updated = mutableListOf<AnimeId>() + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + updated.add(animeId) + return true + } + } + + val deadEntries = mutableListOf<AnimeId>() + val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { + override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { + deadEntries.add(animeId) + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + deadEntriesAccessor = testDeadEntriesAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(deadEntries).containsExactly("previous-test-id") + assertThat(updated).containsExactly("new-test-id") + assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() + assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() + assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() + } + } + + @Test + fun `don't throw an exception if the threshold hasn't been reached`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) + ) + val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true + } + + var updaterHasBeenExecuted = false + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false + override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { + updaterHasBeenExecuted = true + return dcsFile.parent.resolve("new-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX") + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(updaterHasBeenExecuted).isTrue() + } + } + } + + @Test + fun `creates dcs file if it doesn't exist`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + + val expectedDcsEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek(), + _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), + _anime = anime, + ) + + val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(anime).writeToFile(file) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + } + + var receivedDcsEntry: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + receivedDcsEntry = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(receivedDcsEntry).isEqualTo(expectedDcsEntry) + } + } + + @Test + fun `update existing dcs file`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val updatedAnime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + + val updatedDcsEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek(), + _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), + _anime = updatedAnime, + ) + + val initialDcsEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(5), + _nextDownload = WeekOfYear.currentWeek(), + _anime = updatedAnime.copy( + duration = Duration.UNKNOWN, + animeSeason = AnimeSeason( + year = AnimeSeason.UNKNOWN_YEAR, + ), + _tags = hashSetOf( + "action", + "mecha", + "sci-fi", + ), + ), + ) + + val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(file) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + } + + var receivedDcsEntry: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = initialDcsEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + receivedDcsEntry = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(receivedDcsEntry).isEqualTo(updatedDcsEntry) + } + } + + @Test + fun `can handle same entries with different IDs - the case in which the anime id has changed`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + + val file1 = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(anime).writeToFile(file1) + + val file2 = rawdata.resolve("64.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(anime).writeToFile(file2) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + } + + var invocations = 0 + var hasBeenInvoked = false + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { + hasBeenInvoked = true + return file1 + } + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + invocations++ + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(invocations).isEqualTo(2) + assertThat(hasBeenInvoked).isTrue() + } + } + + @Nested + inner class RescheduleEntriesWhichHaveNotBeenFetchedTests { + + @Test + fun `moves an entry which was due this week, but never fetched, to the following week`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val stranded = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + ), + ) + + var received: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(stranded) + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + received = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(received?.lastDownloaded).isEqualTo(stranded.lastDownloaded) + assertThat(received?.weeksWihoutChange).isEqualTo(stranded.weeksWihoutChange) + } + } + + @Test + fun `moves an entry which was due in an earlier week, but never fetched, to the following week`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val overdue = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().plusWeeks(-4), + _nextDownload = WeekOfYear.currentWeek().plusWeeks(-2), + _anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + ), + ) + + var received: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(overdue) + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + received = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(received?.lastDownloaded).isEqualTo(overdue.lastDownloaded) + assertThat(received?.weeksWihoutChange).isEqualTo(overdue.weeksWihoutChange) + } + } + + @Test + fun `leaves an entry alone if it has been fetched`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + ) + Json.toJson(anime).writeToFile(rawdata.resolve("1535.$CONVERTED_FILE_SUFFIX").createFile()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val fetched = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek(), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + val receivedWeeks = mutableListOf<WeekOfYear>() + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(fetched) + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + receivedWeeks.add(downloadControlStateEntry.nextDownload) + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(receivedWeeks).hasSize(1) + } + } + + @Test + fun `does not reschedule anything for a crawler which has been switched off`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val testConfigRegistry = object: ConfigRegistry by TestConfigRegistry { + @Suppress("UNCHECKED_CAST") + override fun <T : Any> list(key: String): List<T>? = when (key) { + "modb.app.disabledCrawlers" -> listOf(AnilistConfig.hostname()) as List<T> + else -> null + } + override fun int(key: String): Int? = null + } + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = shouldNotBeInvoked() + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + configRegistry = testConfigRegistry, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + // shouldNotBeInvoked in the accessor is the assertion + } + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultDownloadControlStateUpdater.instance + + // when + val result = DefaultDownloadControlStateUpdater.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateUpdater::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt index 6426b7e8..1087fcbc 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt @@ -1,1138 +1,1138 @@ -package io.github.manamiproject.modb.app.downloadcontrolstate - -import io.github.manamiproject.modb.app.minusWeeks -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.date.WeekOfYear -import io.github.manamiproject.modb.test.exceptionExpected -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.EnumSource -import org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE -import org.junit.jupiter.params.provider.EnumSource.Mode.INCLUDE -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class DownloadControlStateEntryTest { - - @Nested - inner class ConstructorTests { - - @Test - fun `throws exception if _weeksWihoutChange is negative`() { - // when - val result = exceptionExpected<IllegalArgumentException> { - DownloadControlStateEntry( - _weeksWihoutChange = -1, - _lastDownloaded = WeekOfYear( - year = 2021, - week = 1, - ), - _nextDownload = WeekOfYear( - year = 2021, - week = 2, - ), - _anime = AnimeRaw("title"), - ) - } - - // then - assertThat(result).hasMessage("_weeksWihoutChange must not be negative.") - } - } - - @Nested - inner class UpdateTests { - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime provides changes then schedule redownload for next week`(status: AnimeStatus) { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw("title"), - ) - - val newAnime = AnimeRaw( - _title = "Title", - episodes = 12, - status = status, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime doesn't change first time schedule redownload in 2 to 4 weeks`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status, - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isOne() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isIn( - WeekOfYear.currentWeek().plusWeeks(2).week, - WeekOfYear.currentWeek().plusWeeks(3).week, - WeekOfYear.currentWeek().plusWeeks(4).week, - ) - assertThat(result.anime).isEqualTo(anime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime didn't change repeatedly expand waiting by the weeks without changes so far`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status, - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isEqualTo(5) - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(5).week) - assertThat(result.anime).isEqualTo(anime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime didn't change repeatedly expand waiting time for 12 weeks maximum`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status, - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 10, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isEqualTo(13) - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(12).week) - assertThat(result.anime).isEqualTo(anime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = INCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime didn't change, but the status is either ONGOING or UPCOMING then schedule redownload for next week`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(result.anime) - } - - @Test - fun `different title is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw("title"), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _title = "other", - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different sources is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _sources = hashSetOf(URI("https://example.org/anime/some-title")), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _sources = hashSetOf(URI("https://example.org/anime/other-title")), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different type is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - type = UNKNOWN_TYPE, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - type = MOVIE, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different episodes is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - episodes = 0, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - episodes = 12, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different status is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - status = UNKNOWN_STATUS, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - status = ONGOING, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different animeSeason is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - animeSeason = AnimeSeason( - year = 2025, - ), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - animeSeason = AnimeSeason( - season = FALL, - year = 2025, - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different picture is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - picture = NO_PICTURE, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - picture = URI("https://example.org/media/1.png"), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different thumbnail is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - thumbnail = NO_PICTURE, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - thumbnail = URI("https://example.org/media/1.png"), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different duration is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - duration = UNKNOWN_DURATION, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - duration = Duration( - value = 20, - unit = MINUTES, - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different synonyms is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _synonyms = hashSetOf("Alternative title"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _synonyms = hashSetOf( - "Alternative title", - "Additional title", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different relatedAnime is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _relatedAnime = hashSetOf(URI("https://example.org/anime/some-title")), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _relatedAnime = hashSetOf( - URI("https://example.org/anime/some-title"), - URI("https://example.org/anime/additional-title"), - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different tags is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _tags = hashSetOf("descriptive tag"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _tags = hashSetOf( - "descriptive tag", - "additional tag", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different studios is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _studios = hashSetOf("main studio"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _studios = hashSetOf( - "main studio", - "additional studio", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different producers is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _producers = hashSetOf("a producers"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _producers = hashSetOf( - "a producer", - "additional producers", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different score is NOT considered a change, therefore next download is scheduled for an unchanged anime, but the score is being updated`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 7.8, - range = 1.0..10.0 - ) - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy().addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 7.9, - range = 1.0..10.0 - ) - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isOne() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isIn( - WeekOfYear.currentWeek().plusWeeks(2).week, - WeekOfYear.currentWeek().plusWeeks(3).week, - WeekOfYear.currentWeek().plusWeeks(4).week, - ) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different activateChecks is NOT considered a change, therefore next download is scheduled for an unchanged anime`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - activateChecks = false, - ) - ) - - val newAnime = downloadControlStateEntry.anime.copy( - activateChecks = true, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isOne() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isIn( - WeekOfYear.currentWeek().plusWeeks(2).week, - WeekOfYear.currentWeek().plusWeeks(3).week, - WeekOfYear.currentWeek().plusWeeks(4).week, - ) - assertThat(result.anime).isEqualTo(newAnime) - } - } - - @Nested - inner class CalculateQualityScoreTests { - - @Test - fun `decreasing number of synonyms increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _synonyms = hashSetOf("abcd", "efgh"), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _synonyms = hashSetOf("efgh"), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing type to UNKNOWN increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - type = TV, - ), - ) - - val anime = AnimeRaw( - _title = "Test", - type = UNKNOWN_TYPE, - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `setting number of episodes to zero if it was greater than 0 before increases the score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - episodes = 12, - ), - ) - - val anime = AnimeRaw( - _title = "Test", - episodes = 0, - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing status to UNKNOWN increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - status = UPCOMING - ), - ) - - val anime = AnimeRaw( - _title = "Test", - status = UNKNOWN_STATUS, - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing season to UNDEFINED increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = FALL, - year = 2023, - ) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = UNDEFINED, - year = 2023, - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `setting year to zero if it was greater than 0 before increases the score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = FALL, - year = 2023, - ) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = FALL, - year = 0, - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing picture to default increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - picture = URI("https://example.org/picture.jpg"), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing thumbnail to default increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - thumbnail = URI("https://example.org/picture.jpg"), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `setting duration to zero if it was greater than 0 before increases the score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - duration = Duration( - value = 24, - unit = MINUTES, - ) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `increase score if number if related anime dropped to 0`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf(URI("https://example.org/real1")) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `decreasing number of tags increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "abcd", - "efgh", - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "efgh", - ), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `each score indicator is counted`() { - // given - val anime = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - type = UNKNOWN_TYPE, - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - type = SPECIAL, - episodes = 1, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2009, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), - _synonyms = hashSetOf( - "Clannad ~After Story~: Another World, Kyou Chapter", - "Clannad: After Story OVA", - "クラナド アフターストーリー もうひとつの世界 杏編", - ), - _relatedAnime = hashSetOf(URI("https://myanimelist.net/anime/2167")), - _tags = hashSetOf( - "comedy", - "drama", - "romance", - "school", - "slice of life", - "supernatural", - ), - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(11u) - } - - @Test - fun `return 0 if none of the score indicators apply`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - type = SPECIAL, - episodes = 1, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2009, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), - _synonyms = hashSetOf( - "Clannad ~After Story~: Another World, Kyou Chapter", - "Clannad: After Story OVA", - "クラナド アフターストーリー もうひとつの世界 杏編", - ), - _relatedAnime = hashSetOf(URI("https://myanimelist.net/anime/2167")), - _tags = hashSetOf( - "comedy", - "drama", - "romance", - "school", - "slice of life", - "supernatural", - ), - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(downloadControlStateEntry.anime) - - // then - assertThat(result).isEqualTo(0u) - } - - @Test - fun `decreasing number of scores increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 75.0, - range = 1.0..100.0, - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `decreasing number of studios increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "abcd", - "efgh", - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "efgh", - ), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `decreasing number of producers increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "abcd", - "efgh", - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "efgh", - ), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.downloadcontrolstate + +import io.github.manamiproject.modb.app.minusWeeks +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.date.WeekOfYear +import io.github.manamiproject.modb.test.exceptionExpected +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE +import org.junit.jupiter.params.provider.EnumSource.Mode.INCLUDE +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class DownloadControlStateEntryTest { + + @Nested + inner class ConstructorTests { + + @Test + fun `throws exception if _weeksWihoutChange is negative`() { + // when + val result = exceptionExpected<IllegalArgumentException> { + DownloadControlStateEntry( + _weeksWihoutChange = -1, + _lastDownloaded = WeekOfYear( + year = 2021, + week = 1, + ), + _nextDownload = WeekOfYear( + year = 2021, + week = 2, + ), + _anime = AnimeRaw("title"), + ) + } + + // then + assertThat(result).hasMessage("_weeksWihoutChange must not be negative.") + } + } + + @Nested + inner class UpdateTests { + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime provides changes then schedule redownload for next week`(status: AnimeStatus) { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw("title"), + ) + + val newAnime = AnimeRaw( + _title = "Title", + episodes = 12, + status = status, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime doesn't change first time schedule redownload in 2 to 4 weeks`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status, + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isOne() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isIn( + WeekOfYear.currentWeek().plusWeeks(2).week, + WeekOfYear.currentWeek().plusWeeks(3).week, + WeekOfYear.currentWeek().plusWeeks(4).week, + ) + assertThat(result.anime).isEqualTo(anime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime didn't change repeatedly expand waiting by the weeks without changes so far`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status, + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isEqualTo(5) + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(5).week) + assertThat(result.anime).isEqualTo(anime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime didn't change repeatedly expand waiting time for 12 weeks maximum`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status, + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 10, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isEqualTo(13) + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(12).week) + assertThat(result.anime).isEqualTo(anime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = INCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime didn't change, but the status is either ONGOING or UPCOMING then schedule redownload for next week`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(result.anime) + } + + @Test + fun `different title is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw("title"), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _title = "other", + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different sources is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _sources = hashSetOf(URI("https://example.org/anime/some-title")), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _sources = hashSetOf(URI("https://example.org/anime/other-title")), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different type is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + type = UNKNOWN_TYPE, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + type = MOVIE, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different episodes is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + episodes = 0, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + episodes = 12, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different status is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + status = UNKNOWN_STATUS, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + status = ONGOING, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different animeSeason is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + animeSeason = AnimeSeason( + year = 2025, + ), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + animeSeason = AnimeSeason( + season = FALL, + year = 2025, + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different picture is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + picture = NO_PICTURE, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + picture = URI("https://example.org/media/1.png"), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different thumbnail is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + thumbnail = NO_PICTURE, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + thumbnail = URI("https://example.org/media/1.png"), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different duration is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + duration = UNKNOWN_DURATION, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + duration = Duration( + value = 20, + unit = MINUTES, + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different synonyms is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _synonyms = hashSetOf("Alternative title"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _synonyms = hashSetOf( + "Alternative title", + "Additional title", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different relatedAnime is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _relatedAnime = hashSetOf(RelatedAnime(URI("https://example.org/anime/some-title"))), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://example.org/anime/some-title")), + RelatedAnime(URI("https://example.org/anime/additional-title")), + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different tags is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _tags = hashSetOf("descriptive tag"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _tags = hashSetOf( + "descriptive tag", + "additional tag", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different studios is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _studios = hashSetOf("main studio"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _studios = hashSetOf( + "main studio", + "additional studio", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different producers is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _producers = hashSetOf("a producers"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _producers = hashSetOf( + "a producer", + "additional producers", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different score is NOT considered a change, therefore next download is scheduled for an unchanged anime, but the score is being updated`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 7.8, + range = 1.0..10.0 + ) + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy().addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 7.9, + range = 1.0..10.0 + ) + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isOne() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isIn( + WeekOfYear.currentWeek().plusWeeks(2).week, + WeekOfYear.currentWeek().plusWeeks(3).week, + WeekOfYear.currentWeek().plusWeeks(4).week, + ) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different activateChecks is NOT considered a change, therefore next download is scheduled for an unchanged anime`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + activateChecks = false, + ) + ) + + val newAnime = downloadControlStateEntry.anime.copy( + activateChecks = true, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isOne() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isIn( + WeekOfYear.currentWeek().plusWeeks(2).week, + WeekOfYear.currentWeek().plusWeeks(3).week, + WeekOfYear.currentWeek().plusWeeks(4).week, + ) + assertThat(result.anime).isEqualTo(newAnime) + } + } + + @Nested + inner class CalculateQualityScoreTests { + + @Test + fun `decreasing number of synonyms increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _synonyms = hashSetOf("abcd", "efgh"), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _synonyms = hashSetOf("efgh"), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing type to UNKNOWN increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + type = TV, + ), + ) + + val anime = AnimeRaw( + _title = "Test", + type = UNKNOWN_TYPE, + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `setting number of episodes to zero if it was greater than 0 before increases the score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + episodes = 12, + ), + ) + + val anime = AnimeRaw( + _title = "Test", + episodes = 0, + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing status to UNKNOWN increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + status = UPCOMING + ), + ) + + val anime = AnimeRaw( + _title = "Test", + status = UNKNOWN_STATUS, + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing season to UNDEFINED increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = FALL, + year = 2023, + ) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = UNDEFINED, + year = 2023, + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `setting year to zero if it was greater than 0 before increases the score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = FALL, + year = 2023, + ) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = FALL, + year = 0, + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing picture to default increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + picture = URI("https://example.org/picture.jpg"), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing thumbnail to default increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + thumbnail = URI("https://example.org/picture.jpg"), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `setting duration to zero if it was greater than 0 before increases the score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + duration = Duration( + value = 24, + unit = MINUTES, + ) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `increase score if number if related anime dropped to 0`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf(RelatedAnime(URI("https://example.org/real1"))) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `decreasing number of tags increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "abcd", + "efgh", + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "efgh", + ), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `each score indicator is counted`() { + // given + val anime = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + type = UNKNOWN_TYPE, + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + type = SPECIAL, + episodes = 1, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2009, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), + _synonyms = hashSetOf( + "Clannad ~After Story~: Another World, Kyou Chapter", + "Clannad: After Story OVA", + "クラナド アフターストーリー もうひとつの世界 杏編", + ), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2167"))), + _tags = hashSetOf( + "comedy", + "drama", + "romance", + "school", + "slice of life", + "supernatural", + ), + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(11u) + } + + @Test + fun `return 0 if none of the score indicators apply`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + type = SPECIAL, + episodes = 1, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2009, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), + _synonyms = hashSetOf( + "Clannad ~After Story~: Another World, Kyou Chapter", + "Clannad: After Story OVA", + "クラナド アフターストーリー もうひとつの世界 杏編", + ), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2167"))), + _tags = hashSetOf( + "comedy", + "drama", + "romance", + "school", + "slice of life", + "supernatural", + ), + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(downloadControlStateEntry.anime) + + // then + assertThat(result).isEqualTo(0u) + } + + @Test + fun `decreasing number of scores increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 75.0, + range = 1.0..100.0, + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `decreasing number of studios increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "abcd", + "efgh", + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "efgh", + ), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `decreasing number of producers increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "abcd", + "efgh", + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "efgh", + ), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt index d1b694b4..2c1777ed 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt @@ -1,540 +1,541 @@ -package io.github.manamiproject.modb.app.merging - -import io.github.manamiproject.modb.app.TestAppConfig -import io.github.manamiproject.modb.app.TestMergeLockAccessor -import io.github.manamiproject.modb.app.config.AppConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.merging.goldenrecords.DefaultGoldenRecordAccessor -import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SUMMER -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.WINTER -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test - -internal class DefaultMergingServiceTest { - - @Nested - inner class MergeEntryTests { - - @Test - fun `merge three equal anime with slightly different titles`() { - runTest { - // given - val aniList11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/6682"), - ), - _title = "11eyes", - type = TV, - episodes = 12, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 2009, - ), - _synonyms = hashSetOf( - "11eyes -Tsumi to Batsu to Aganai no Shoujo-", - "イレブンアイズ", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/110465"), - URI("https://anilist.co/anime/7739"), - ), - ) - - val anidb11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/6751"), - ), - _title = "11 Eyes", - type = TV, - episodes = 12, - picture = URI("https://cdn.anidb.net/images/main/32901.jpg"), - thumbnail = URI("https://cdn.anidb.net/images/main/32901.jpg-thumb.jpg"), - status = FINISHED, - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 2009, - ), - _synonyms = hashSetOf( - "11 akių", - "11 глаз", - "11 چشم", - "11eyes", - "11eyes -罪與罰與贖的少女-", - "11eyes: Tsumi to Batsu to Aganai no Shoujo", - "أحد عشر عيناً", - "イレブンアイズ", - "罪与罚与赎的少女", - ), - ) - - val mal11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/6682"), - ), - _title = "11 - eyes", - type = TV, - episodes = 12, - picture = URI("https://cdn.myanimelist.net/images/anime/6/73520.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/73520t.jpg"), - status = FINISHED, - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 2009, - ), - _synonyms = hashSetOf( - "11eyes -Tsumi to Batsu to Aganai no Shoujo-", - "11eyes イレブンアイズ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/20557"), - URI("https://myanimelist.net/anime/7739"), - ), - ) - - val animeList = listOf(aniList11Eyes, anidb11Eyes, mal11Eyes) - - val expectedMergedAnime = anidb11Eyes.copy().apply { - addSources(anidb11Eyes.sources) - addSynonyms(anidb11Eyes.synonyms) - addRelatedAnime(anidb11Eyes.relatedAnime) - addTags(anidb11Eyes.tags) - - } - .mergeWith(aniList11Eyes) - .mergeWith(mal11Eyes) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockAccessor = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockAccessor, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder(expectedMergedAnime) - } - } - - @Test - fun `don't merge two totally different anime`() { - runTest { - // given - val anidb = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/1"), - ), - _title = "Seikai no Monshou", - type = TV, - episodes = 13, - picture = URI("https://cdn.anidb.net/images/main/224618.jpg"), - thumbnail = URI("https://cdn.anidb.net/images/main/224618.jpg-thumb.jpg"), - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 1999, - ), - _synonyms = hashSetOf( - "CotS", - "Crest of the Stars", - "Hvězdný erb", - "SnM", - "星界の紋章", - "星界之纹章", - ), - _relatedAnime = hashSetOf( - URI("https://anidb.net/anime/1623"), - URI("https://anidb.net/anime/4"), - URI("https://anidb.net/anime/6"), - ), - ) - - val mal = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val animeList = listOf(anidb, mal) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder(anidb, mal) - } - } - - @Test - fun `merge two totally different anime, because they are part of a merge lock`() { - runTest { - // given - val aniList11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/6682"), - ), - _title = "11eyes", - type = TV, - episodes = 12, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - _synonyms = hashSetOf( - "11eyes -Tsumi to Batsu to Aganai no Shoujo-", - "イレブンアイズ", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/110465"), - URI("https://anilist.co/anime/7739"), - ), - ) - - val bungakuShoujo = AnimeRaw( - _sources = hashSetOf( - URI("https://kitsu.app/anime/4550"), - URI("https://myanimelist.net/anime/6408"), - ), - _title = "\"Bungaku Shoujo\" Movie", - type = MOVIE, - episodes = 1, - status = FINISHED, - picture = URI("https://cdn.myanimelist.net/images/anime/8/81162.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/8/81162t.jpg"), - _synonyms = hashSetOf( - "Book Girl", - "Literature Girl", - "げきじょうばんぶんがくしょうじょ", - "劇場版“文学少女”", - ), - _relatedAnime = hashSetOf( - URI("https://kitsu.app/anime/5078"), - URI("https://kitsu.app/anime/5352"), - URI("https://myanimelist.net/anime/7669"), - URI("https://myanimelist.net/anime/8481"), - ), - ) - - val expectedEntry = aniList11Eyes.copy().apply { - addSources(aniList11Eyes.sources) - addSynonyms(aniList11Eyes.synonyms) - addRelatedAnime(aniList11Eyes.relatedAnime) - addTags(aniList11Eyes.tags) - }.mergeWith(bungakuShoujo) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = true - override suspend fun getMergeLock(uri: URI): Set<URI> = aniList11Eyes.sources.union(bungakuShoujo.sources) - } - - val animeList = listOf(aniList11Eyes, bungakuShoujo) - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactly(expectedEntry) - } - } - - @Test - fun `multiple golden records`() { - runTest { - // given - val entry1 = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/59110"), - ), - _title = "daydream", - episodes = 1, - animeSeason = AnimeSeason( - season = WINTER, - year = 2024, - ), - status = FINISHED, - type = SPECIAL, - duration = Duration( - value = 120, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "白昼夢", - ), - ) - - val entry2 = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/58364"), - ), - _title = "daydream", - episodes = 1, - type = SPECIAL, - status = FINISHED, - animeSeason = AnimeSeason( - season = WINTER, - year = 2024, - ), - duration = Duration( - value = 120, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "崩壊学園9周年記念楽曲「白昼夢」", - "houkai gakuen 9th anniversary song daydream", - "houkai gakuen 9th anniversary song daydream", - "崩壊学園9周年記念楽曲「白昼夢」", - ), - ) - - val entry3 = AnimeRaw( - _sources = hashSetOf( - URI("https://anisearch.com/anime/19120"), - ), - _title = "Daydream", - episodes = 1, - type = ONA, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2001, - ), - duration = Duration( - value = 240, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "春猿火 - daydream", - ), - ) - - val expectedResult = listOf( - entry1.copy().mergeWith(entry2), - entry3, - ) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val animeList = listOf(entry1, entry3, entry2) - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder( - *expectedResult.toTypedArray(), - ) - } - } - - @Test - fun `found multiple potential golden records, but none returns sufficient probability and therefore the entries set for retry`() { - runTest { - // given - val malCowboyBebop = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val malDaydream = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/59110"), - ), - _title = "daydream", - episodes = 1, - animeSeason = AnimeSeason( - season = WINTER, - year = 2024, - ), - status = FINISHED, - type = SPECIAL, - duration = Duration( - value = 120, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "白昼夢", - ), - ) - - val aniSearchDaydream = AnimeRaw( - _sources = hashSetOf( - URI("https://anisearch.com/anime/19120"), - ), - _title = "Daydream", - episodes = 4, - type = ONA, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2001, - ), - duration = Duration( - value = 240, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "春猿火 - daydream", - ), - ) - - val anilDaydream = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/10001"), - ), - _title = "Daydream", - episodes = 1, - type = MOVIE, - status = UPCOMING, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2006, - ), - duration = Duration( - value = 1, - unit = HOURS, - ), - ) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val animeList = listOf(malCowboyBebop, malDaydream, aniSearchDaydream, anilDaydream) - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder( - malCowboyBebop, - malDaydream, - aniSearchDaydream, - anilDaydream, - ) - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultMergingService.instance - - // when - val result = DefaultMergingService.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultMergingService::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.merging + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.app.TestAppConfig +import io.github.manamiproject.modb.app.TestMergeLockAccessor +import io.github.manamiproject.modb.app.config.AppConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.merging.goldenrecords.DefaultGoldenRecordAccessor +import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SUMMER +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.WINTER +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test + +internal class DefaultMergingServiceTest { + + @Nested + inner class MergeEntryTests { + + @Test + fun `merge three equal anime with slightly different titles`() { + runTest { + // given + val aniList11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/6682"), + ), + _title = "11eyes", + type = TV, + episodes = 12, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 2009, + ), + _synonyms = hashSetOf( + "11eyes -Tsumi to Batsu to Aganai no Shoujo-", + "イレブンアイズ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/110465")), + RelatedAnime(URI("https://anilist.co/anime/7739")), + ), + ) + + val anidb11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/6751"), + ), + _title = "11 Eyes", + type = TV, + episodes = 12, + picture = URI("https://cdn.anidb.net/images/main/32901.jpg"), + thumbnail = URI("https://cdn.anidb.net/images/main/32901.jpg-thumb.jpg"), + status = FINISHED, + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 2009, + ), + _synonyms = hashSetOf( + "11 akių", + "11 глаз", + "11 چشم", + "11eyes", + "11eyes -罪與罰與贖的少女-", + "11eyes: Tsumi to Batsu to Aganai no Shoujo", + "أحد عشر عيناً", + "イレブンアイズ", + "罪与罚与赎的少女", + ), + ) + + val mal11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/6682"), + ), + _title = "11 - eyes", + type = TV, + episodes = 12, + picture = URI("https://cdn.myanimelist.net/images/anime/6/73520.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/73520t.jpg"), + status = FINISHED, + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 2009, + ), + _synonyms = hashSetOf( + "11eyes -Tsumi to Batsu to Aganai no Shoujo-", + "11eyes イレブンアイズ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/20557")), + RelatedAnime(URI("https://myanimelist.net/anime/7739")), + ), + ) + + val animeList = listOf(aniList11Eyes, anidb11Eyes, mal11Eyes) + + val expectedMergedAnime = anidb11Eyes.copy().apply { + addSources(anidb11Eyes.sources) + addSynonyms(anidb11Eyes.synonyms) + addRelatedAnime(anidb11Eyes.relatedAnime) + addTags(anidb11Eyes.tags) + + } + .mergeWith(aniList11Eyes) + .mergeWith(mal11Eyes) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockAccessor = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockAccessor, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder(expectedMergedAnime) + } + } + + @Test + fun `don't merge two totally different anime`() { + runTest { + // given + val anidb = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/1"), + ), + _title = "Seikai no Monshou", + type = TV, + episodes = 13, + picture = URI("https://cdn.anidb.net/images/main/224618.jpg"), + thumbnail = URI("https://cdn.anidb.net/images/main/224618.jpg-thumb.jpg"), + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 1999, + ), + _synonyms = hashSetOf( + "CotS", + "Crest of the Stars", + "Hvězdný erb", + "SnM", + "星界の紋章", + "星界之纹章", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/1623")), + RelatedAnime(URI("https://anidb.net/anime/4")), + RelatedAnime(URI("https://anidb.net/anime/6")), + ), + ) + + val mal = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val animeList = listOf(anidb, mal) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder(anidb, mal) + } + } + + @Test + fun `merge two totally different anime, because they are part of a merge lock`() { + runTest { + // given + val aniList11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/6682"), + ), + _title = "11eyes", + type = TV, + episodes = 12, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + _synonyms = hashSetOf( + "11eyes -Tsumi to Batsu to Aganai no Shoujo-", + "イレブンアイズ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/110465")), + RelatedAnime(URI("https://anilist.co/anime/7739")), + ), + ) + + val bungakuShoujo = AnimeRaw( + _sources = hashSetOf( + URI("https://kitsu.app/anime/4550"), + URI("https://myanimelist.net/anime/6408"), + ), + _title = "\"Bungaku Shoujo\" Movie", + type = MOVIE, + episodes = 1, + status = FINISHED, + picture = URI("https://cdn.myanimelist.net/images/anime/8/81162.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/8/81162t.jpg"), + _synonyms = hashSetOf( + "Book Girl", + "Literature Girl", + "げきじょうばんぶんがくしょうじょ", + "劇場版“文学少女”", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://kitsu.app/anime/5078")), + RelatedAnime(URI("https://kitsu.app/anime/5352")), + RelatedAnime(URI("https://myanimelist.net/anime/7669")), + RelatedAnime(URI("https://myanimelist.net/anime/8481")), + ), + ) + + val expectedEntry = aniList11Eyes.copy().apply { + addSources(aniList11Eyes.sources) + addSynonyms(aniList11Eyes.synonyms) + addRelatedAnime(aniList11Eyes.relatedAnime) + addTags(aniList11Eyes.tags) + }.mergeWith(bungakuShoujo) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = true + override suspend fun getMergeLock(uri: URI): Set<URI> = aniList11Eyes.sources.union(bungakuShoujo.sources) + } + + val animeList = listOf(aniList11Eyes, bungakuShoujo) + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactly(expectedEntry) + } + } + + @Test + fun `multiple golden records`() { + runTest { + // given + val entry1 = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/59110"), + ), + _title = "daydream", + episodes = 1, + animeSeason = AnimeSeason( + season = WINTER, + year = 2024, + ), + status = FINISHED, + type = SPECIAL, + duration = Duration( + value = 120, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "白昼夢", + ), + ) + + val entry2 = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/58364"), + ), + _title = "daydream", + episodes = 1, + type = SPECIAL, + status = FINISHED, + animeSeason = AnimeSeason( + season = WINTER, + year = 2024, + ), + duration = Duration( + value = 120, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "崩壊学園9周年記念楽曲「白昼夢」", + "houkai gakuen 9th anniversary song daydream", + "houkai gakuen 9th anniversary song daydream", + "崩壊学園9周年記念楽曲「白昼夢」", + ), + ) + + val entry3 = AnimeRaw( + _sources = hashSetOf( + URI("https://anisearch.com/anime/19120"), + ), + _title = "Daydream", + episodes = 1, + type = ONA, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2001, + ), + duration = Duration( + value = 240, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "春猿火 - daydream", + ), + ) + + val expectedResult = listOf( + entry1.copy().mergeWith(entry2), + entry3, + ) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val animeList = listOf(entry1, entry3, entry2) + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder( + *expectedResult.toTypedArray(), + ) + } + } + + @Test + fun `found multiple potential golden records, but none returns sufficient probability and therefore the entries set for retry`() { + runTest { + // given + val malCowboyBebop = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val malDaydream = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/59110"), + ), + _title = "daydream", + episodes = 1, + animeSeason = AnimeSeason( + season = WINTER, + year = 2024, + ), + status = FINISHED, + type = SPECIAL, + duration = Duration( + value = 120, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "白昼夢", + ), + ) + + val aniSearchDaydream = AnimeRaw( + _sources = hashSetOf( + URI("https://anisearch.com/anime/19120"), + ), + _title = "Daydream", + episodes = 4, + type = ONA, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2001, + ), + duration = Duration( + value = 240, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "春猿火 - daydream", + ), + ) + + val anilDaydream = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/10001"), + ), + _title = "Daydream", + episodes = 1, + type = MOVIE, + status = UPCOMING, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2006, + ), + duration = Duration( + value = 1, + unit = HOURS, + ), + ) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val animeList = listOf(malCowboyBebop, malDaydream, aniSearchDaydream, anilDaydream) + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder( + malCowboyBebop, + malDaydream, + aniSearchDaydream, + anilDaydream, + ) + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultMergingService.instance + + // when + val result = DefaultMergingService.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultMergingService::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt index 0366cbd4..d34748db 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt @@ -1,1105 +1,1106 @@ -package io.github.manamiproject.modb.app.merging.goldenrecords - -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.util.* -import kotlin.test.Test - -internal class DefaultGoldenRecordAccessorTest { - - @Nested - inner class CreateGoldenRecordTests { - - @Test - fun `create a new golden record`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw("Death Note") - - // when - goldenRecordList.createGoldenRecord(anime) - - // then - assertThat(goldenRecordList.allEntries()).containsExactly(anime) - } - - @Test - fun `creating a new golden record based on the exact same anime will create another new golden record, because there is no duplicate check`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw("Death Note") - goldenRecordList.createGoldenRecord(anime) - - // when - goldenRecordList.createGoldenRecord(anime) - - // then - assertThat(goldenRecordList.allEntries()).hasSize(2) - assertThat(goldenRecordList.allEntries()).containsExactly(anime, anime) - } - } - - @Nested - inner class FindGoldenRecordBySources { - - @Test - fun `returns null, because a golden record does not exist for that source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - // when - val result = goldenRecordList.findGoldenRecordBySource(hashSetOf(URI("https://myanimelist.net/anime/1535"))) - - // then - assertThat(result).isNull() - } - - @Test - fun `finds golden record by a single source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - URI("https://myanimelist.net/anime/37515"), - ) - ) - - goldenRecordList.createGoldenRecord(anime) - - // when - val result = goldenRecordList.findGoldenRecordBySource(anime.sources)!! - - // then - assertThat(result.anime).isEqualTo(anime) - } - - @Test - fun `finds golden record by multiple sources where there is only a golden record for the last source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - URI("https://myanimelist.net/anime/37515"), - ) - ) - - goldenRecordList.createGoldenRecord(anime) - - // when - val result = goldenRecordList.findGoldenRecordBySource( - hashSetOf( - URI("https://anilist.co/anime/101343"), - URI("https://kitsu.app/anime/41080"), - anime.sources.first(), - ) - )!! - - // then - assertThat(result.anime).isEqualTo(anime) - } - } - - @Nested - inner class FindPossibleGoldenRecordsTests { - - @Test - fun `return empty list for matching episode, but non-matching main title`() { - // given - val anime = AnimeRaw( - _title = "Made in Abyss", - episodes = 13, - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _title = "Searching for", - episodes = 13, - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(anime) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = ["Kaiketsu Zorori Da-Da-Da-Daibōken!", "Kaiketsu Zorori Da-Da-Da-Daibouken!"]) - fun `finds the same entry whether a long vowel is written with a macron or spelled out`(title: String) { - // given - val goldenRecord = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/14093"), - ), - _title = "Kaiketsu Zorori Da-Da-Da-Daibouken!", - type = MOVIE, - episodes = 1, - ) - - val entryToMatch = AnimeRaw( - _sources = hashSetOf( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=13787"), - ), - _title = title, - type = MOVIE, - episodes = 1, - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(goldenRecord) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) - - // then - assertThat(result).hasSize(1) - assertThat(result.first().anime.title).isEqualTo("Kaiketsu Zorori Da-Da-Da-Daibouken!") - } - - @Test - fun `does not match a title which merely shares a prefix`() { - // given - val goldenRecord = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/14093"), - ), - _title = "Slime Boukenki: Umi da, Yeah", - type = MOVIE, - episodes = 1, - ) - - val entryToMatch = AnimeRaw( - _sources = hashSetOf( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=1"), - ), - _title = "Slime Boukenki: Yama da, Yeah", - type = MOVIE, - episodes = 1, - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(goldenRecord) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) - - // then - assertThat(result).isEmpty() - } - - @Test - fun `find correct entry for matching main title`() { - // given - val madeInAbyss = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - ), - _title = "Made in Abyss", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/86733.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/86733t.jpg"), - _synonyms = hashSetOf( - "メイドインアビス", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/36862"), - URI("https://myanimelist.net/anime/37514"), - URI("https://myanimelist.net/anime/37515"), - ), - ) - - val madeInAbyssMovie = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - URI("https://myanimelist.net/anime/37515"), - ), - ) - - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "A Place Further Than The Universe", - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/37123"), - ), - ) - - val soraYoriMoTooiBashoSpecial = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37123") - ), - _title = "Sora yori mo Tooi Basho: Yokoku", - type = ONA, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), - _synonyms = hashSetOf( - "Sora yori mo Tooi Basho next episode previews", - "宇宙よりも遠い場所 予告 WEB限定", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/97986"), - ), - _title = "Made in Abyss", - type = TV, - episodes = 13, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(madeInAbyss) - goldenRecordList.createGoldenRecord(madeInAbyssMovie) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(madeInAbyss) - } - - @Test - fun `find correct entry for matching title which is shorter than the partition size`() { - // given - val animeWithTitleShorterThanPartitionSize = AnimeRaw( - _sources = hashSetOf( - URI("https://example.org.net/anime/y"), - ), - _title = "Y", - ) - - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "A Place Further Than The Universe", - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/37123"), - ), - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://otherexample.com/anime/994"), - ), - _title = "Y", - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(animeWithTitleShorterThanPartitionSize) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(animeWithTitleShorterThanPartitionSize) - } - - @ParameterizedTest - @ValueSource(strings = [ - "!", - "¡", - "!", - "?", - "¿", - "?", - "#", - "#", - "%", - "%", - "*", - "*", - "+", - "+", - ",", - ",", - "、", - ".", - ".", - "。", - "・", - "。", - "/", - "/", - "∕", - "⁄", - "\\", - "=", - "=", - "@", - "@", - "|", - "|", - "$", - "$", - "¥", - "¥", - "¢", - "□", - "▲", - "△", - "▼", - "▽", - "◆", - "◇", - "○", - "◎", - "●", - "◯", - "★", - "☆", - "☠", - "♀", - "♂", - "♡", - "♥", - "♪", - "♭", - "♯", - "⚡", - "✞", - "✩", - "✶", - "✽", - "✿", - "❄", - "❤", - "⤴", - "←", - "↑", - "→", - "⇔", - "℃", - "‼", - "⁈", - "※", - "®", - "√", - "…", - "ª", - "ⁿ", - "・", - "·", - "‧", - "、", - "␣", - "∞", - "∅", - "∀", - "ː", - "±", - "°", - "•", - "†", - "\"", - "'", - "^", - "`", - "’", - "‘", - "“", - "”", - "„", - "″", - "〝", - "〟", - "´", - "ʼ", - "‚", - ":", - ":", - ";", - ";", - "-", - "_", - "—", - "―", - "–", - "_", - "‐", - "‑", - "‒", - "−", - "─", - "ー️", - "-", - "ー", - "~", - "∽", - "~", - "〜", - "<", - ">", - "<", - ">", - "(", - ")", - "(", - ")", - "[", - "]", - "[", - "]", - "{", - "}", - "{", - "}", - "「", - "」", - "「", - "」", - "〈", - "〉", - "《", - "》", - "『", - "』", - "【", - "】", - "〔", - "〕", - "≪", - "≫", - "«", - "»", - "≦", - "≧", - "≠", - "∬", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "", - "᠎", - "⁠", - "‍", - "", - "‌", - "​", - "­", - "", - "
", - " ", - " ", - ]) - fun `find title by ignoring special char`(specialChar: String) { - // given - val titleWithoutSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - URI("https://myanimelist.net/anime/37515"), - ) - ) - - val titleWithSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://example.org/anime/37514"), - ), - _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(titleWithSpecialChar) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) - } - - @ParameterizedTest - @CsvSource( - "&,&", - "☓,x", - "×,x", - "ß,ss", - "ⅰ,i", - "ⅱ,ii", - "ⅲ,iii", - "ⅳ,iv", - "ⅴ,v", - "ⅵ,vi", - "ⅶ,vii", - "ⅺ,xi", - "½,12", - "⅙,16", - "⅛,18", - "²,2", - "³,3", - "№,no", - "②,2", - "⑤,5", - "a,a", - "b,b", - "c,c", - "d,d", - "e,e", - "f,f", - "g,g", - "h,h", - "i,i", - "j,j", - "k,k", - "l,l", - "m,m", - "n,n", - "o,o", - "p,p", - "q,q", - "r,r", - "s,s", - "t,t", - "u,u", - "v,v", - "w,w", - "x,x", - "y,y", - "z,z", - "0,0", - "1,1", - "2,2", - "3,3", - "4,4", - "5,5", - "6,6", - "7,7", - "8,8", - "9,9", - ) - fun `returns entry, because it correctly replaces special chars`(specialChar: String, replacement: String) { - // given - val titleWithoutSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie $replacement Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - URI("https://myanimelist.net/anime/37515"), - ) - ) - - val titleWithSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://example.org/anime/37514"), - ), - _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(titleWithSpecialChar) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) - } - - @Test - fun `returns multiple possible golden records, because the anime share the same synonym`() { - // given - val madeInAbyssMovie = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け" - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - URI("https://myanimelist.net/anime/37515"), - ), - ) - - val madeInAbyssMovie2 = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37515"), - ), - _title = "Made in Abyss Movie 2: Hourou Suru Tasogare", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1336/95168.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1336/95168t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 2: Wandering Twilight", - "劇場版総集編【後編】メイドインアビス 放浪する黄昏", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - URI("https://myanimelist.net/anime/37515"), - ) - ) - - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "A Place Further Than The Universe", - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/37123"), - ), - ) - - val soraYoriMoTooiBashoSpecial = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37123"), - ), - _title = "Sora yori mo Tooi Basho: Yokoku", - type = ONA, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), - _synonyms = hashSetOf( - "Sora yori mo Tooi Basho next episode previews", - "宇宙よりも遠い場所 予告 WEB限定", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ) - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/97986"), - ), - _title = "Made in Abyss Movie", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg") - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(madeInAbyssMovie) - goldenRecordList.createGoldenRecord(madeInAbyssMovie2) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - val resultingRecords = result.map { it.anime } - assertThat(result.size).isEqualTo(2) - assertThat(resultingRecords).contains(madeInAbyssMovie) - assertThat(resultingRecords).contains(madeInAbyssMovie2) - } - - @Test - fun `if nothing could be found for the title and the source is anime-planet then try the first synonym`() { - // given - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/37123"), - ), - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://anime-planet.com/anime/a-place-further-than-the-universe"), - ), - _title = "A Place Further Than the Universe", - type = TV, - episodes = 13, - _synonyms = hashSetOf( - "Sora yori mo Tooi Basho", - ), - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(soraYoriMoTooiBasho) - } - } - - @Nested - inner class MergeTests { - - @Test - fun `throws an exception if a golden record cannot be found for the given uuid`() { - // given - val id = UUID.randomUUID() - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _title = "Death Note", - duration = Duration(25, MINUTES), - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - // when - val result = assertThrows<IllegalStateException> { - goldenRecordList.merge(id, anime) - } - - // then - assertThat(result).hasMessage("Unable to find golden record [$id]") - } - - @Test - fun `adds title and synonyms of the other anime to the anime in the golden record`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val goldenAnime = AnimeRaw( - _title = "Death Note", - duration = Duration(25, MINUTES), - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - goldenRecordList.createGoldenRecord(goldenAnime) - val uuid = goldenRecordList.findPossibleGoldenRecords(goldenAnime).first().id - - val otherAnime = AnimeRaw( - _title = "DEATH NOTE", - duration = Duration(25, MINUTES), - _synonyms = hashSetOf( - "Caderno da Morte", - "Quaderno della Morte", - ), - ) - - // when - val result = goldenRecordList.merge(uuid, otherAnime) - - // then - assertThat(result.title).isEqualTo(result.title) - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Caderno da Morte", - "DEATH NOTE", - "Quaderno della Morte", - ) - } - - @Test - fun `merge related anime and source links`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - ) - - goldenRecordList.createGoldenRecord(anime) - val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id - - val otherAnime = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - URI("https://anidb.net/anime/8146"), - URI("https://anidb.net/anime/8147"), - ), - ) - - // when - val result = goldenRecordList.merge(uuid, otherAnime) - - // then - assertThat(result.sources).containsExactly( - URI("https://anidb.net/anime/4563"), - URI("https://myanimelist.net/anime/1535"), - ) - assertThat(result.relatedAnime).containsExactly( - URI("https://anidb.net/anime/8146"), - URI("https://anidb.net/anime/8147"), - URI("https://myanimelist.net/anime/2994"), - ) - } - - @Test - fun `cannot merge related anime if it's the other anime's source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - ) - - goldenRecordList.createGoldenRecord(anime) - val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id - - val otherAnime = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - URI("https://anidb.net/anime/8147"), - ), - ) - - // when - val result = goldenRecordList.merge(uuid, otherAnime) - - // then - assertThat(result.sources).containsExactly( - URI("https://anidb.net/anime/4563"), - URI("https://myanimelist.net/anime/1535"), - ) - assertThat(result.relatedAnime).containsExactly( - URI("https://anidb.net/anime/8147"), - URI("https://myanimelist.net/anime/2994"), - ) - } - - @Test - fun `correctly updates sources cluster`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _title = "Death Note", - ) - - goldenRecordList.createGoldenRecord(anime) - val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id - - val otherAnime = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _title = "Death Note", - ) - - // when - goldenRecordList.merge(uuid, otherAnime) - - // then - val result = goldenRecordList.findGoldenRecordBySource(setOf(URI("https://anidb.net/anime/4563"))) - assertThat(result).isNotNull() - } - } - - @Nested - inner class ClearTests { - - @Test - fun `clears the list`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw("Death Note") - goldenRecordList.createGoldenRecord(anime) - - // when - goldenRecordList.clear() - - // then - assertThat(goldenRecordList.allEntries()).isEmpty() - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultGoldenRecordAccessor.instance - - // when - val result = DefaultGoldenRecordAccessor.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultGoldenRecordAccessor::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.merging.goldenrecords + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.util.* +import kotlin.test.Test + +internal class DefaultGoldenRecordAccessorTest { + + @Nested + inner class CreateGoldenRecordTests { + + @Test + fun `create a new golden record`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw("Death Note") + + // when + goldenRecordList.createGoldenRecord(anime) + + // then + assertThat(goldenRecordList.allEntries()).containsExactly(anime) + } + + @Test + fun `creating a new golden record based on the exact same anime will create another new golden record, because there is no duplicate check`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw("Death Note") + goldenRecordList.createGoldenRecord(anime) + + // when + goldenRecordList.createGoldenRecord(anime) + + // then + assertThat(goldenRecordList.allEntries()).hasSize(2) + assertThat(goldenRecordList.allEntries()).containsExactly(anime, anime) + } + } + + @Nested + inner class FindGoldenRecordBySources { + + @Test + fun `returns null, because a golden record does not exist for that source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + // when + val result = goldenRecordList.findGoldenRecordBySource(hashSetOf(URI("https://myanimelist.net/anime/1535"))) + + // then + assertThat(result).isNull() + } + + @Test + fun `finds golden record by a single source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + goldenRecordList.createGoldenRecord(anime) + + // when + val result = goldenRecordList.findGoldenRecordBySource(anime.sources)!! + + // then + assertThat(result.anime).isEqualTo(anime) + } + + @Test + fun `finds golden record by multiple sources where there is only a golden record for the last source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + goldenRecordList.createGoldenRecord(anime) + + // when + val result = goldenRecordList.findGoldenRecordBySource( + hashSetOf( + URI("https://anilist.co/anime/101343"), + URI("https://kitsu.app/anime/41080"), + anime.sources.first(), + ) + )!! + + // then + assertThat(result.anime).isEqualTo(anime) + } + } + + @Nested + inner class FindPossibleGoldenRecordsTests { + + @Test + fun `return empty list for matching episode, but non-matching main title`() { + // given + val anime = AnimeRaw( + _title = "Made in Abyss", + episodes = 13, + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _title = "Searching for", + episodes = 13, + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(anime) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = ["Kaiketsu Zorori Da-Da-Da-Daibōken!", "Kaiketsu Zorori Da-Da-Da-Daibouken!"]) + fun `finds the same entry whether a long vowel is written with a macron or spelled out`(title: String) { + // given + val goldenRecord = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/14093"), + ), + _title = "Kaiketsu Zorori Da-Da-Da-Daibouken!", + type = MOVIE, + episodes = 1, + ) + + val entryToMatch = AnimeRaw( + _sources = hashSetOf( + URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=13787"), + ), + _title = title, + type = MOVIE, + episodes = 1, + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(goldenRecord) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) + + // then + assertThat(result).hasSize(1) + assertThat(result.first().anime.title).isEqualTo("Kaiketsu Zorori Da-Da-Da-Daibouken!") + } + + @Test + fun `does not match a title which merely shares a prefix`() { + // given + val goldenRecord = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/14093"), + ), + _title = "Slime Boukenki: Umi da, Yeah", + type = MOVIE, + episodes = 1, + ) + + val entryToMatch = AnimeRaw( + _sources = hashSetOf( + URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=1"), + ), + _title = "Slime Boukenki: Yama da, Yeah", + type = MOVIE, + episodes = 1, + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(goldenRecord) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) + + // then + assertThat(result).isEmpty() + } + + @Test + fun `find correct entry for matching main title`() { + // given + val madeInAbyss = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/34599"), + ), + _title = "Made in Abyss", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/86733.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/86733t.jpg"), + _synonyms = hashSetOf( + "メイドインアビス", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/36862")), + RelatedAnime(URI("https://myanimelist.net/anime/37514")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ), + ) + + val madeInAbyssMovie = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ), + ) + + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "A Place Further Than The Universe", + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val soraYoriMoTooiBashoSpecial = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37123") + ), + _title = "Sora yori mo Tooi Basho: Yokoku", + type = ONA, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), + _synonyms = hashSetOf( + "Sora yori mo Tooi Basho next episode previews", + "宇宙よりも遠い場所 予告 WEB限定", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/35839")), + ), + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/97986"), + ), + _title = "Made in Abyss", + type = TV, + episodes = 13, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(madeInAbyss) + goldenRecordList.createGoldenRecord(madeInAbyssMovie) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(madeInAbyss) + } + + @Test + fun `find correct entry for matching title which is shorter than the partition size`() { + // given + val animeWithTitleShorterThanPartitionSize = AnimeRaw( + _sources = hashSetOf( + URI("https://example.org.net/anime/y"), + ), + _title = "Y", + ) + + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "A Place Further Than The Universe", + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://otherexample.com/anime/994"), + ), + _title = "Y", + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(animeWithTitleShorterThanPartitionSize) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(animeWithTitleShorterThanPartitionSize) + } + + @ParameterizedTest + @ValueSource(strings = [ + "!", + "¡", + "!", + "?", + "¿", + "?", + "#", + "#", + "%", + "%", + "*", + "*", + "+", + "+", + ",", + ",", + "、", + ".", + ".", + "。", + "・", + "。", + "/", + "/", + "∕", + "⁄", + "\\", + "=", + "=", + "@", + "@", + "|", + "|", + "$", + "$", + "¥", + "¥", + "¢", + "□", + "▲", + "△", + "▼", + "▽", + "◆", + "◇", + "○", + "◎", + "●", + "◯", + "★", + "☆", + "☠", + "♀", + "♂", + "♡", + "♥", + "♪", + "♭", + "♯", + "⚡", + "✞", + "✩", + "✶", + "✽", + "✿", + "❄", + "❤", + "⤴", + "←", + "↑", + "→", + "⇔", + "℃", + "‼", + "⁈", + "※", + "®", + "√", + "…", + "ª", + "ⁿ", + "・", + "·", + "‧", + "、", + "␣", + "∞", + "∅", + "∀", + "ː", + "±", + "°", + "•", + "†", + "\"", + "'", + "^", + "`", + "’", + "‘", + "“", + "”", + "„", + "″", + "〝", + "〟", + "´", + "ʼ", + "‚", + ":", + ":", + ";", + ";", + "-", + "_", + "—", + "―", + "–", + "_", + "‐", + "‑", + "‒", + "−", + "─", + "ー️", + "-", + "ー", + "~", + "∽", + "~", + "〜", + "<", + ">", + "<", + ">", + "(", + ")", + "(", + ")", + "[", + "]", + "[", + "]", + "{", + "}", + "{", + "}", + "「", + "」", + "「", + "」", + "〈", + "〉", + "《", + "》", + "『", + "』", + "【", + "】", + "〔", + "〕", + "≪", + "≫", + "«", + "»", + "≦", + "≧", + "≠", + "∬", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "", + "᠎", + "⁠", + "‍", + "", + "‌", + "​", + "­", + "", + "
", + " ", + " ", + ]) + fun `find title by ignoring special char`(specialChar: String) { + // given + val titleWithoutSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + val titleWithSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://example.org/anime/37514"), + ), + _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(titleWithSpecialChar) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) + } + + @ParameterizedTest + @CsvSource( + "&,&", + "☓,x", + "×,x", + "ß,ss", + "ⅰ,i", + "ⅱ,ii", + "ⅲ,iii", + "ⅳ,iv", + "ⅴ,v", + "ⅵ,vi", + "ⅶ,vii", + "ⅺ,xi", + "½,12", + "⅙,16", + "⅛,18", + "²,2", + "³,3", + "№,no", + "②,2", + "⑤,5", + "a,a", + "b,b", + "c,c", + "d,d", + "e,e", + "f,f", + "g,g", + "h,h", + "i,i", + "j,j", + "k,k", + "l,l", + "m,m", + "n,n", + "o,o", + "p,p", + "q,q", + "r,r", + "s,s", + "t,t", + "u,u", + "v,v", + "w,w", + "x,x", + "y,y", + "z,z", + "0,0", + "1,1", + "2,2", + "3,3", + "4,4", + "5,5", + "6,6", + "7,7", + "8,8", + "9,9", + ) + fun `returns entry, because it correctly replaces special chars`(specialChar: String, replacement: String) { + // given + val titleWithoutSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie $replacement Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + val titleWithSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://example.org/anime/37514"), + ), + _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(titleWithSpecialChar) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) + } + + @Test + fun `returns multiple possible golden records, because the anime share the same synonym`() { + // given + val madeInAbyssMovie = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け" + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ), + ) + + val madeInAbyssMovie2 = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37515"), + ), + _title = "Made in Abyss Movie 2: Hourou Suru Tasogare", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1336/95168.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1336/95168t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 2: Wandering Twilight", + "劇場版総集編【後編】メイドインアビス 放浪する黄昏", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "A Place Further Than The Universe", + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val soraYoriMoTooiBashoSpecial = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37123"), + ), + _title = "Sora yori mo Tooi Basho: Yokoku", + type = ONA, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), + _synonyms = hashSetOf( + "Sora yori mo Tooi Basho next episode previews", + "宇宙よりも遠い場所 予告 WEB限定", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/35839")), + ) + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/97986"), + ), + _title = "Made in Abyss Movie", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg") + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(madeInAbyssMovie) + goldenRecordList.createGoldenRecord(madeInAbyssMovie2) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + val resultingRecords = result.map { it.anime } + assertThat(result.size).isEqualTo(2) + assertThat(resultingRecords).contains(madeInAbyssMovie) + assertThat(resultingRecords).contains(madeInAbyssMovie2) + } + + @Test + fun `if nothing could be found for the title and the source is anime-planet then try the first synonym`() { + // given + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://anime-planet.com/anime/a-place-further-than-the-universe"), + ), + _title = "A Place Further Than the Universe", + type = TV, + episodes = 13, + _synonyms = hashSetOf( + "Sora yori mo Tooi Basho", + ), + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(soraYoriMoTooiBasho) + } + } + + @Nested + inner class MergeTests { + + @Test + fun `throws an exception if a golden record cannot be found for the given uuid`() { + // given + val id = UUID.randomUUID() + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _title = "Death Note", + duration = Duration(25, MINUTES), + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + // when + val result = assertThrows<IllegalStateException> { + goldenRecordList.merge(id, anime) + } + + // then + assertThat(result).hasMessage("Unable to find golden record [$id]") + } + + @Test + fun `adds title and synonyms of the other anime to the anime in the golden record`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val goldenAnime = AnimeRaw( + _title = "Death Note", + duration = Duration(25, MINUTES), + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + goldenRecordList.createGoldenRecord(goldenAnime) + val uuid = goldenRecordList.findPossibleGoldenRecords(goldenAnime).first().id + + val otherAnime = AnimeRaw( + _title = "DEATH NOTE", + duration = Duration(25, MINUTES), + _synonyms = hashSetOf( + "Caderno da Morte", + "Quaderno della Morte", + ), + ) + + // when + val result = goldenRecordList.merge(uuid, otherAnime) + + // then + assertThat(result.title).isEqualTo(result.title) + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Caderno da Morte", + "DEATH NOTE", + "Quaderno della Morte", + ) + } + + @Test + fun `merge related anime and source links`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + goldenRecordList.createGoldenRecord(anime) + val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id + + val otherAnime = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + ), + ) + + // when + val result = goldenRecordList.merge(uuid, otherAnime) + + // then + assertThat(result.sources).containsExactly( + URI("https://anidb.net/anime/4563"), + URI("https://myanimelist.net/anime/1535"), + ) + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ) + } + + @Test + fun `cannot merge related anime if it's the other anime's source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + goldenRecordList.createGoldenRecord(anime) + val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id + + val otherAnime = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/1535")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + ), + ) + + // when + val result = goldenRecordList.merge(uuid, otherAnime) + + // then + assertThat(result.sources).containsExactly( + URI("https://anidb.net/anime/4563"), + URI("https://myanimelist.net/anime/1535"), + ) + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://anidb.net/anime/8147")), + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ) + } + + @Test + fun `correctly updates sources cluster`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _title = "Death Note", + ) + + goldenRecordList.createGoldenRecord(anime) + val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id + + val otherAnime = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _title = "Death Note", + ) + + // when + goldenRecordList.merge(uuid, otherAnime) + + // then + val result = goldenRecordList.findGoldenRecordBySource(setOf(URI("https://anidb.net/anime/4563"))) + assertThat(result).isNotNull() + } + } + + @Nested + inner class ClearTests { + + @Test + fun `clears the list`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw("Death Note") + goldenRecordList.createGoldenRecord(anime) + + // when + goldenRecordList.clear() + + // then + assertThat(goldenRecordList.allEntries()).isEmpty() + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultGoldenRecordAccessor.instance + + // when + val result = DefaultGoldenRecordAccessor.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultGoldenRecordAccessor::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt index 50b65e64..e02b76a1 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt @@ -1,827 +1,827 @@ -package io.github.manamiproject.modb.app.merging.matching - -import io.github.manamiproject.modb.app.merging.goldenrecords.PotentialGoldenRecord -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING -import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SPRING -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import java.net.URI -import java.util.UUID.randomUUID -import kotlin.test.Test - -internal class DefaultMatchingProbabilityCalculatorTest { - - @Nested - inner class CalculateTests { - - @Nested - inner class BasePropertiesTests { - - @Test - fun `matches 100 percent with basic properties, because the anime for is the same as in the potential golden record`() { - // given - val anime = AnimeRaw("Cowboy Bebop") - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy() - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @Test - fun `title differs - matches 93 percent`() { - // given - val anime = AnimeRaw("Cowboy Bebop") - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - _title = "- Cowboy Bebop -", - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.93) - } - - @Test - fun `type differs - matches 66 percent`() { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - type = TV, - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = MOVIE, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.66) - } - - @ParameterizedTest - @CsvSource( - "SPECIAL,ONA", - "ONA,SPECIAL", - ) - fun `type within special and ona - matches 80 percent`(animeType: String, potentialGoldenRecordType: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - type = AnimeType.of(animeType), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = AnimeType.of(potentialGoldenRecordType), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.8) - } - - @Test - fun `episodes differ - matches 98 percent`() { - // given - val anime = AnimeRaw("Cowboy Bebop") - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - episodes = 1, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(0.98) - } - - @Test - fun `completely different - matches 0 percent`() { - // given - val anime = AnimeRaw( - _title = "ABC", - episodes = 1, - type = MOVIE, - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = AnimeRaw( - _title = "DEF", - episodes = 1500, - type = TV, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(0.0) - } - - @ParameterizedTest - @CsvSource( - "FINISHED,UNKNOWN", - "UNKNOWN,FINISHED", - ) - fun `ignore status if one of one of the two is UNKNOWN`(animeStatus: String, potentialGoldenRecordStatus: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - status = AnimeStatus.of(animeStatus), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - status = AnimeStatus.of(potentialGoldenRecordStatus), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @ParameterizedTest - @CsvSource( - "1998,0", - "0,1998", - ) - fun `ignore yearOfPremiere if one of one of the two is UNKNOWN`(animeYear: String, potentialGoldenRecordYear: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - animeSeason = AnimeSeason( - year = animeYear.toInt(), - ) - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - animeSeason = AnimeSeason( - year = potentialGoldenRecordYear.toInt(), - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @ParameterizedTest - @CsvSource( - "23,0", - "0,23", - ) - fun `ignore duration if one of one of the two is UNKNOWN`(animeMinutes: String, potentialGoldenRecordMinutes: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - duration = Duration( - value = animeMinutes.toInt(), - unit = MINUTES, - ) - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = potentialGoldenRecordMinutes.toInt(), - unit = MINUTES, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - } - - @Nested - inner class AllPropertiesTests { - - @Test - fun `matches 100 percent with all properties, because the anime for is the same as in the potential golden record`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy() - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @Test - fun `title differs - matches 96 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - _title = "- Cowboy Bebop -", - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.96) - } - - @Test - fun `type differs - matches 83 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = MOVIE, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.83) - } - - @ParameterizedTest - @CsvSource( - "SPECIAL,ONA", - "ONA,SPECIAL", - ) - fun `type within special and ona - matches 90 percent`(animeType: String, potentialGoldenRecordType: String) { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = AnimeType.of(animeType), - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = AnimeType.of(potentialGoldenRecordType), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.9) - } - - @Test - fun `episodes differ - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - episodes = 27, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `status differs - matches 83 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - status = UPCOMING, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.83) - } - - @Test - fun `yearOfPremiere differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - animeSeason = anime.animeSeason.copy( - year = 1999, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `duration in minutes differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = 23, - unit = MINUTES, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `duration in seconds differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 30, - unit = SECONDS, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = 31, - unit = SECONDS, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `duration in hours differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 2, - unit = HOURS, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/17205"), - URI("https://myanimelist.net/anime/4037"), - URI("https://myanimelist.net/anime/5"), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = 3, - unit = HOURS, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `completely different - matches 0 percent`() { - // given - val anime = AnimeRaw( - _title = "ABC", - type = MOVIE, - episodes = 1, - status = UPCOMING, - duration = Duration( - value = 2, - unit = HOURS, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 2025, - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = AnimeRaw( - _title = "DEF", - type = TV, - episodes = 1500, - status = FINISHED, - duration = Duration( - value = 30, - unit = SECONDS, - ), - animeSeason = AnimeSeason( - season = FALL, - year = 1981, - ), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.0) - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultMatchingProbabilityCalculator.instance - - // when - val result = DefaultMatchingProbabilityCalculator.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultMatchingProbabilityCalculator::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.merging.matching + +import io.github.manamiproject.modb.app.merging.goldenrecords.PotentialGoldenRecord +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING +import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SPRING +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import java.net.URI +import java.util.UUID.randomUUID +import kotlin.test.Test + +internal class DefaultMatchingProbabilityCalculatorTest { + + @Nested + inner class CalculateTests { + + @Nested + inner class BasePropertiesTests { + + @Test + fun `matches 100 percent with basic properties, because the anime for is the same as in the potential golden record`() { + // given + val anime = AnimeRaw("Cowboy Bebop") + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy() + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @Test + fun `title differs - matches 93 percent`() { + // given + val anime = AnimeRaw("Cowboy Bebop") + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + _title = "- Cowboy Bebop -", + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.93) + } + + @Test + fun `type differs - matches 66 percent`() { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + type = TV, + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = MOVIE, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.66) + } + + @ParameterizedTest + @CsvSource( + "SPECIAL,ONA", + "ONA,SPECIAL", + ) + fun `type within special and ona - matches 80 percent`(animeType: String, potentialGoldenRecordType: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + type = AnimeType.of(animeType), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = AnimeType.of(potentialGoldenRecordType), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.8) + } + + @Test + fun `episodes differ - matches 98 percent`() { + // given + val anime = AnimeRaw("Cowboy Bebop") + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + episodes = 1, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(0.98) + } + + @Test + fun `completely different - matches 0 percent`() { + // given + val anime = AnimeRaw( + _title = "ABC", + episodes = 1, + type = MOVIE, + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = AnimeRaw( + _title = "DEF", + episodes = 1500, + type = TV, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(0.0) + } + + @ParameterizedTest + @CsvSource( + "FINISHED,UNKNOWN", + "UNKNOWN,FINISHED", + ) + fun `ignore status if one of one of the two is UNKNOWN`(animeStatus: String, potentialGoldenRecordStatus: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + status = AnimeStatus.of(animeStatus), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + status = AnimeStatus.of(potentialGoldenRecordStatus), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @ParameterizedTest + @CsvSource( + "1998,0", + "0,1998", + ) + fun `ignore yearOfPremiere if one of one of the two is UNKNOWN`(animeYear: String, potentialGoldenRecordYear: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + animeSeason = AnimeSeason( + year = animeYear.toInt(), + ) + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + animeSeason = AnimeSeason( + year = potentialGoldenRecordYear.toInt(), + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @ParameterizedTest + @CsvSource( + "23,0", + "0,23", + ) + fun `ignore duration if one of one of the two is UNKNOWN`(animeMinutes: String, potentialGoldenRecordMinutes: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + duration = Duration( + value = animeMinutes.toInt(), + unit = MINUTES, + ) + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = potentialGoldenRecordMinutes.toInt(), + unit = MINUTES, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + } + + @Nested + inner class AllPropertiesTests { + + @Test + fun `matches 100 percent with all properties, because the anime for is the same as in the potential golden record`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy() + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @Test + fun `title differs - matches 96 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + _title = "- Cowboy Bebop -", + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.96) + } + + @Test + fun `type differs - matches 83 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = MOVIE, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.83) + } + + @ParameterizedTest + @CsvSource( + "SPECIAL,ONA", + "ONA,SPECIAL", + ) + fun `type within special and ona - matches 90 percent`(animeType: String, potentialGoldenRecordType: String) { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = AnimeType.of(animeType), + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = AnimeType.of(potentialGoldenRecordType), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.9) + } + + @Test + fun `episodes differ - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + episodes = 27, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `status differs - matches 83 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + status = UPCOMING, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.83) + } + + @Test + fun `yearOfPremiere differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + animeSeason = anime.animeSeason.copy( + year = 1999, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `duration in minutes differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = 23, + unit = MINUTES, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `duration in seconds differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 30, + unit = SECONDS, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = 31, + unit = SECONDS, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `duration in hours differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 2, + unit = HOURS, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = 3, + unit = HOURS, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `completely different - matches 0 percent`() { + // given + val anime = AnimeRaw( + _title = "ABC", + type = MOVIE, + episodes = 1, + status = UPCOMING, + duration = Duration( + value = 2, + unit = HOURS, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 2025, + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = AnimeRaw( + _title = "DEF", + type = TV, + episodes = 1500, + status = FINISHED, + duration = Duration( + value = 30, + unit = SECONDS, + ), + animeSeason = AnimeSeason( + season = FALL, + year = 1981, + ), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.0) + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultMatchingProbabilityCalculator.instance + + // when + val result = DefaultMatchingProbabilityCalculator.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultMatchingProbabilityCalculator::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt index 115dc113..9389c0b7 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt @@ -1,91 +1,92 @@ -package io.github.manamiproject.modb.app.relatedanime - -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test - -internal class DefaultUnknownRelatedAnimeRemoverTest { - - @Nested - inner class RemoveRelatedAnimeWhichHaveNoCorrespondingEntryInSourcesTests { - - @Test - fun `removes related anime listed which don't have a corresponding anime entry`() { - // given - val animeList = listOf( - AnimeRaw( - _title = "1", - _sources = hashSetOf( - URI("http://config1.example.org/anime/1"), - URI("http://config2.example.org/anime/first-entry"), - ), - _relatedAnime = hashSetOf( - URI("http://config1.example.org/anime/2"), - URI("http://config1.example.org/anime/25"), - URI("http://config2.example.org/anime/second-entry"), - URI("http://config2.example.org/anime/non-existent-entry"), - ) - ), - AnimeRaw( - _title = "2", - _sources = hashSetOf( - URI("http://config1.example.org/anime/2"), - URI("http://config2.example.org/anime/second-entry"), - ), - _relatedAnime = hashSetOf( - URI("http://config1.example.org/anime/1"), - URI("http://config1.example.org/anime/25"), - URI("http://config2.example.org/anime/first-entry"), - ), - ), - AnimeRaw( - _title = "3", - _sources = hashSetOf( - URI("http://config2.example.org/anime/third-entry"), - ), - _relatedAnime = hashSetOf( - URI("http://config2.example.org/anime/non-existent-entry"), - ), - ), - ) - - val defaultUnknownRelatedAnimeRemover = DefaultUnknownRelatedAnimeRemover() - - // when - val result = defaultUnknownRelatedAnimeRemover.removeRelatedAnimeWhichHaveNoCorrespondingEntryInSources(animeList) - - // then - assertThat(result.find { it.title == "1" }!!.relatedAnime).containsExactlyInAnyOrder( - URI("http://config1.example.org/anime/2"), - URI("http://config2.example.org/anime/second-entry"), - ) - assertThat(result.find { it.title == "2" }!!.relatedAnime).containsExactlyInAnyOrder( - URI("http://config1.example.org/anime/1"), - URI("http://config2.example.org/anime/first-entry"), - ) - assertThat(result.find { it.title == "3" }!!.relatedAnime).isEmpty() - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultUnknownRelatedAnimeRemover.instance - - // when - val result = DefaultUnknownRelatedAnimeRemover.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultUnknownRelatedAnimeRemover::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.relatedanime + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test + +internal class DefaultUnknownRelatedAnimeRemoverTest { + + @Nested + inner class RemoveRelatedAnimeWhichHaveNoCorrespondingEntryInSourcesTests { + + @Test + fun `removes related anime listed which don't have a corresponding anime entry`() { + // given + val animeList = listOf( + AnimeRaw( + _title = "1", + _sources = hashSetOf( + URI("http://config1.example.org/anime/1"), + URI("http://config2.example.org/anime/first-entry"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("http://config1.example.org/anime/2")), + RelatedAnime(URI("http://config1.example.org/anime/25")), + RelatedAnime(URI("http://config2.example.org/anime/second-entry")), + RelatedAnime(URI("http://config2.example.org/anime/non-existent-entry")), + ) + ), + AnimeRaw( + _title = "2", + _sources = hashSetOf( + URI("http://config1.example.org/anime/2"), + URI("http://config2.example.org/anime/second-entry"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("http://config1.example.org/anime/1")), + RelatedAnime(URI("http://config1.example.org/anime/25")), + RelatedAnime(URI("http://config2.example.org/anime/first-entry")), + ), + ), + AnimeRaw( + _title = "3", + _sources = hashSetOf( + URI("http://config2.example.org/anime/third-entry"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("http://config2.example.org/anime/non-existent-entry")), + ), + ), + ) + + val defaultUnknownRelatedAnimeRemover = DefaultUnknownRelatedAnimeRemover() + + // when + val result = defaultUnknownRelatedAnimeRemover.removeRelatedAnimeWhichHaveNoCorrespondingEntryInSources(animeList) + + // then + assertThat(result.find { it.title == "1" }!!.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("http://config1.example.org/anime/2")), + RelatedAnime(URI("http://config2.example.org/anime/second-entry")), + ) + assertThat(result.find { it.title == "2" }!!.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("http://config1.example.org/anime/1")), + RelatedAnime(URI("http://config2.example.org/anime/first-entry")), + ) + assertThat(result.find { it.title == "3" }!!.relatedAnime).isEmpty() + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultUnknownRelatedAnimeRemover.instance + + // when + val result = DefaultUnknownRelatedAnimeRemover.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultUnknownRelatedAnimeRemover::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemoverTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemoverTest.kt index 3511aea1..ad57eb10 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemoverTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/synonyms/DefaultAmbiguousSynonymRemoverTest.kt @@ -1,5 +1,6 @@ package io.github.manamiproject.modb.app.synonyms +import io.github.manamiproject.modb.core.anime.RelatedAnime import io.github.manamiproject.modb.core.anime.AnimeRaw import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Nested @@ -18,13 +19,13 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Clevatess: Majuu no Ou to Akago to Shikabane no Yuusha", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("Clevatess"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val season2 = AnimeRaw( _title = "Clevatess II: Majuu no Ou to Itsuwari no Yuusha Denshou", _sources = hashSetOf(URI("http://example.org/anime/2")), _synonyms = hashSetOf("Clevatess", "Clevatess Season 2"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when @@ -42,7 +43,7 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Jibaku Shounen Hanako-kun", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("Toilet-bound Hanako-kun", "地縛少年花子くん"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val season2 = AnimeRaw( _title = "Jibaku Shounen Hanako-kun 2", @@ -53,7 +54,7 @@ internal class DefaultAmbiguousSynonymRemoverTest { "地縛少年花子くん", "地縛少年花子くん 第2期", ), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when @@ -73,12 +74,12 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Monster: The Perfect Edition", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("Monster", "Monster 2"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val unrelatedName = AnimeRaw( _title = "Naoki Urasawa", _sources = hashSetOf(URI("http://example.org/anime/2")), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when @@ -95,13 +96,13 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Kikou Kantai Dairugger XV", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("Voltron"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val second = AnimeRaw( _title = "Hyakujuu-Ou GoLion", _sources = hashSetOf(URI("http://example.org/anime/2")), _synonyms = hashSetOf("Voltron"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when @@ -119,13 +120,13 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Hunter x Hunter", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("Hunter x Hunter (1999)"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val remake = AnimeRaw( _title = "Hunter x Hunter (2011)", _sources = hashSetOf(URI("http://example.org/anime/2")), _synonyms = hashSetOf("Hunter x Hunter"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when @@ -142,13 +143,13 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Ashita no Joe", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("Rocky Joe", "Rocky Joe 1"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val season2 = AnimeRaw( _title = "Ashita no Joe 2", _sources = hashSetOf(URI("http://example.org/anime/2")), _synonyms = hashSetOf("Rocky Joe", "Rocky Joe 2"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when @@ -182,7 +183,7 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Clevatess II", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("Clevatess", "Clevatess Season 2"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/unknown")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/unknown"))), ) // when @@ -199,13 +200,13 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Kyouryuu Shoujo Gauko", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("\u6050\u7adc\u5c11\u5973\u30ac\u30a6\u5b50"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val season2 = AnimeRaw( _title = "Kyouryuu Shoujo Gauko 2nd Season", _sources = hashSetOf(URI("http://example.org/anime/2")), _synonyms = hashSetOf("\u6050\u7adc\u5c11\u5973\u30ac\u30a6\u5b50", "\u6050\u7adc\u5c11\u5973\u30ac\u30a6\u5b50 \uff12"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when @@ -222,7 +223,7 @@ internal class DefaultAmbiguousSynonymRemoverTest { _title = "Sengoku Youko", _sources = hashSetOf(URI("http://example.org/anime/1")), _synonyms = hashSetOf("\u6226\u56fd\u5996\u72d0 \u4e16\u76f4\u3057\u59c9\u5f1f\u7de8"), - _relatedAnime = hashSetOf(URI("http://example.org/anime/2")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/2"))), ) val recap = AnimeRaw( _title = "Sengoku Youko: Yonaoshi Kyoudai-hen Recap", @@ -231,7 +232,7 @@ internal class DefaultAmbiguousSynonymRemoverTest { "\u6226\u56fd\u5996\u72d0\u3000\u4e16\u76f4\u3057\u59c9\u5f1f\u7de8", "\u6226\u56fd\u5996\u72d0\u3000\u4e16\u76f4\u3057\u59c9\u5f1f\u7de8 \u7b2c2\u671f", ), - _relatedAnime = hashSetOf(URI("http://example.org/anime/1")), + _relatedAnime = hashSetOf(RelatedAnime(URI("http://example.org/anime/1"))), ) // when diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md index 6d072302..bb9d3864 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md @@ -1,357 +1,399 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(0% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 2 | [myanimelist.net](https://myanimelist.net) | -| 0 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | `URL[]` | no | URLs to the metadata providers for anime that are somehow related to this anime. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://anidb.net/anime/8146", - "https://anidb.net/anime/8147", - "https://anilist.co/anime/20931", - "https://anilist.co/anime/2994", - "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god", - "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors", - "https://animecountdown.com/36687", - "https://animecountdown.com/40690", - "https://anisearch.com/anime/4441", - "https://anisearch.com/anime/5194", - "https://kitsu.app/anime/2707", - "https://livechart.me/anime/3808", - "https://myanimelist.net/anime/2994", - "https://simkl.com/anime/36687", - "https://simkl.com/anime/40690" - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(0% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 2 | [myanimelist.net](https://myanimelist.net) | +| 0 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md index 820d7e97..80213c68 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md @@ -1,364 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(0% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | `URL[]` | no | URLs to the metadata providers for anime that are somehow related to this anime. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://anidb.net/anime/8146", - "https://anidb.net/anime/8147", - "https://anilist.co/anime/20931", - "https://anilist.co/anime/2994", - "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god", - "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors", - "https://animecountdown.com/36687", - "https://animecountdown.com/40690", - "https://anisearch.com/anime/4441", - "https://anisearch.com/anime/5194", - "https://kitsu.app/anime/2707", - "https://livechart.me/anime/3808", - "https://myanimelist.net/anime/2994", - "https://simkl.com/anime/36687", - "https://simkl.com/anime/40690" - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(0% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md index 2e312c7c..c2e98091 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md @@ -1,364 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(50% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | `URL[]` | no | URLs to the metadata providers for anime that are somehow related to this anime. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://anidb.net/anime/8146", - "https://anidb.net/anime/8147", - "https://anilist.co/anime/20931", - "https://anilist.co/anime/2994", - "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god", - "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors", - "https://animecountdown.com/36687", - "https://animecountdown.com/40690", - "https://anisearch.com/anime/4441", - "https://anisearch.com/anime/5194", - "https://kitsu.app/anime/2707", - "https://livechart.me/anime/3808", - "https://myanimelist.net/anime/2994", - "https://simkl.com/anime/36687", - "https://simkl.com/anime/40690" - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(50% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md index 218af01c..b80b624d 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md @@ -1,364 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(100% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | `URL[]` | no | URLs to the metadata providers for anime that are somehow related to this anime. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://anidb.net/anime/8146", - "https://anidb.net/anime/8147", - "https://anilist.co/anime/20931", - "https://anilist.co/anime/2994", - "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god", - "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors", - "https://animecountdown.com/36687", - "https://animecountdown.com/40690", - "https://anisearch.com/anime/4441", - "https://anisearch.com/anime/5194", - "https://kitsu.app/anime/2707", - "https://livechart.me/anime/3808", - "https://myanimelist.net/anime/2994", - "https://simkl.com/anime/36687", - "https://simkl.com/anime/40690" - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(100% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md index 2921785a..48002d1e 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md @@ -1,364 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-02/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 02 [2020]** - -The dataset consists of **2** entries _(0% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | `URL[]` | no | URLs to the metadata providers for anime that are somehow related to this anime. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://anidb.net/anime/8146", - "https://anidb.net/anime/8147", - "https://anilist.co/anime/20931", - "https://anilist.co/anime/2994", - "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god", - "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors", - "https://animecountdown.com/36687", - "https://animecountdown.com/40690", - "https://anisearch.com/anime/4441", - "https://anisearch.com/anime/5194", - "https://kitsu.app/anime/2707", - "https://livechart.me/anime/3808", - "https://myanimelist.net/anime/2994", - "https://simkl.com/anime/36687", - "https://simkl.com/anime/40690" - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-02/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 02 [2020]** + +The dataset consists of **2** entries _(0% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt b/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt index d4a98e23..0164949d 100644 --- a/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt +++ b/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt @@ -1,336 +1,337 @@ -package io.github.manamiproject.modb.livechart - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.normalize -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import kotlinx.coroutines.withContext -import org.apache.commons.text.StringEscapeUtils -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property xmlExtractor Extractor which retrieves the data from raw data. - * @property jsonExtractor Extractor which retrieves the data from raw data. - */ -public class LivechartAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = LivechartConfig, - private val xmlExtractor: DataExtractor = XmlDataExtractor, - private val jsonExtractor: DataExtractor = JsonDataExtractor, -): AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = xmlExtractor.extract(rawContent, mapOf( - "jsonld" to "//script[@type='application/ld+json']/node()", - "title" to "//meta[@property='og:title']/@content", - "image" to "//meta[@property='og:image']/@content", - "episodesDiv" to "//div[text()='Episodes']/../text()", - "episodesCountdown" to "//div[@data-controller='countdown-bar']//div[contains(text(), 'EP')]/text()", - "type" to "//div[text()='Format']/..", - "status" to "//div[text()='Status']/..", - "duration" to "//div[text()='Run time']/..", - "season" to "//div[text()='Season']/../a/text()", - "year" to "//div[text()='Premiere']/following-sibling::*", - "relatedAnime" to "//div[@data-controller='carousel']//article/a/@href", - "tags" to "//div[text()='Tags']/..//a[@data-anime-details-target='tagChip']", - "sourceDiv" to "//div[@data-anime-details-id]/@data-anime-details-id", - "sourceMeta" to "//meta[@property='og:url']/@content", - "studios" to "//a[contains(@href, 'studios')]/text()", - )) - - val jsonld = data.listNotNull<String>("jsonld").first() - val jsonldData = jsonExtractor.extract(jsonld, mapOf( - "url" to "$.url", - "genre" to "$.genre", - "name" to "$.name", - "image" to "$.image", - "numberOfEpisodes" to "$.numberOfEpisodes", - "datePublished" to "$.datePublished", - "alternateName" to "$.alternateName", - "score" to "$.aggregateRating.ratingValue", - "worstRating" to "$.aggregateRating.worstRating", - "bestRating" to "$.aggregateRating.bestRating", - "productionCompany" to "$.productionCompany.*.name", - )) - - val picture = extractPicture(jsonldData, data) - - return@withContext AnimeRaw( - _title = extractTitle(jsonldData, data), - episodes = extractEpisodes(jsonldData, data), - type = extractType(data), - picture = picture, - thumbnail = findThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(jsonldData, data), - _synonyms = extractSynonyms(jsonldData), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(jsonldData, data), - _studios = extractStudios(jsonldData, data), - _producers = hashSetOf(), // not available on livechart - ).addScores(extractScore(jsonldData)) - } - - private fun extractTitle(jsonData: ExtractionResult, data: ExtractionResult): Title { - val name = jsonData.stringOrDefault("name").ifBlank { - data.stringOrDefault("title") - } - - return StringEscapeUtils.unescapeHtml4(name) - } - - private fun extractEpisodes(jsonData: ExtractionResult, data: ExtractionResult): Episodes { - // JSON data - var episodes = jsonData.intOrDefault("numberOfEpisodes") - var isTableValueUnknown = false - - // regular value in data table - if (episodes == 0) { - val episodesValue = data.stringOrDefault("episodesDiv", "?") - .trim() - .split('/') - .last() - - if (episodesValue != "?" && episodesValue != "-") { - episodes = episodesValue.toIntOrNull() ?: 0 - } else { - isTableValueUnknown = true - } - } - - // current episode from table if the anime is ongoing - if (episodes == 0) { - episodes = data.stringOrDefault("episodesCountdown") - .remove("EP") - .trim() - .toIntOrNull() - ?: 0 - - if (episodes != 0 ) { - episodes -= 1 - } - } - - return when { - episodes == 0 && isTableValueUnknown -> 1 - else -> episodes - } - } - - private fun extractType(data: ExtractionResult): AnimeType { - val value = data.string("type") - - return when(value.trim().lowercase()) { - "movie" -> MOVIE - "ova" -> OVA - "special" -> SPECIAL - "tv" -> TV - "tv short" -> TV - "tv special" -> SPECIAL - "web" -> ONA - "web short" -> ONA - "?" -> UNKNOWN_TYPE - "unknown" -> UNKNOWN_TYPE - else -> throw IllegalStateException("Unknown type [$value]") - } - } - - private fun extractPicture(jsonData: ExtractionResult, data: ExtractionResult): URI { - val value = jsonData.stringOrDefault("image").ifBlank { - data.stringOrDefault("image") - } - - return if (!value.endsWith(LARGE_PICTURE_INDICATOR)) { - NO_PICTURE - } else { - URI(value) - } - } - - private fun findThumbnail(uri: URI): URI { - return if (uri == NO_PICTURE) { - NO_PICTURE_THUMBNAIL - } else { - URI(uri.toString().replace(LARGE_PICTURE_INDICATOR, SMALL_PICTURE_INDICATOR)) - } - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val statusString = data.stringOrDefault("status") - - return when (statusString.trim().lowercase()){ - "not yet released" -> UPCOMING - "releasing" -> ONGOING - "finished" -> FINISHED - else -> UNKNOWN_STATUS - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationString = data.stringOrDefault("duration").trim() - - val seconds = """(\d+ ?[aA-zZ]+)+""".toRegex() - .findAll(durationString) - .map { it.value } - .map { - val value = ("""\d+""".toRegex().find(it)?.value?.trim() ?: "0").ifBlank { "0" }.toIntOrNull() ?: 0 - val unit = """[a-z]+""".toRegex().find(it)?.value?.trim()?.lowercase() ?: "" - value to unit - } - .map { - when(it.second) { - "hr", "h" -> it.first * 3600 - "min", "m" -> it.first * 60 - "sec", "s" -> it.first - else -> throw IllegalStateException("Unknown unit [${it.second}]") - } - } - .sum() - - return Duration(seconds, SECONDS) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val splitSeasonString = if (data.notFound("season")) { - listOf(EMPTY) - } else { - data.listNotNull<String>("season").first().remove("Season ").split(' ') - } - - val seasonString = splitSeasonString.first().trim().lowercase() - val season = when(seasonString) { - "winter" -> WINTER - "spring" -> SPRING - "summer" -> SUMMER - "fall" -> FALL - else -> UNDEFINED - } - - val year = if (splitSeasonString.size == 2) { - YEAR_REGEX.find(splitSeasonString[1])?.value?.trim()?.ifBlank { "0" }?.toIntOrNull() ?: 0 - } else { - YEAR_REGEX.find(data.stringOrDefault("year"))?.value?.trim()?.toIntOrNull() ?: 0 - } - - return AnimeSeason( - season = season, - year = year, - ) - } - - private fun extractSourcesEntry(jsonData: ExtractionResult, data: ExtractionResult): HashSet<URI> { - if (!data.notFound("sourceDiv")) { - return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("sourceDiv"))) - } - - if (jsonData.notFound("url")) { - val link = jsonData.string("url").trim().remove("www.").ifBlank { - data.string("sourceMeta").trim().remove("www.") - } - - return hashSetOf(URI(link)) - } - - throw IllegalStateException("Unable to extract source.") - } - - private fun extractSynonyms(jsonData: ExtractionResult): HashSet<Title> { - return jsonData.listNotNull<Title>("alternateName") - .map { StringEscapeUtils.unescapeHtml4(it) } - .toHashSet() - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> { - return if (data.notFound("relatedAnime")) { - hashSetOf() - } else { - data.listNotNull<String>("relatedAnime") - .map { it.remove("/anime/") } - .map { metaDataProviderConfig.buildAnimeLink(it) } - .toHashSet() - } - } - - private fun extractTags(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { - val tags: HashSet<String> = if (jsonData.notFound("genre")) { - hashSetOf() - } else { - jsonData.listNotNull<Tag>("genre") - .map { it.trim().lowercase() } - .toHashSet() - } - - if (!data.notFound("tags")) { - data.listNotNull<Tag>("tags") - .map { it.trim().lowercase() } - .forEach { tags.add(it) } - } - - return tags - } - - private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { - if (jsonldData.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = jsonldData.double("score") - val from = jsonldData.doubleOrDefault("worstRating", 1.0) - val to = jsonldData.doubleOrDefault("bestRating", 10.0) - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = from..to, - ) - } - - private fun extractStudios(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { - val studios: HashSet<Studio> = if (jsonData.notFound("productionCompany")) { - hashSetOf() - } else { - jsonData.listNotNull<Studio>("productionCompany").toHashSet() - } - - if (!data.notFound("studios")) { - data.listNotNull<Studio>("studios") - .map { it.normalize().lowercase() } - .filterNot { it == "studios" } - .forEach { studios.add(it) } - } - - return studios - } - - public companion object { - private const val LARGE_PICTURE_INDICATOR = "large.jpg" - private const val SMALL_PICTURE_INDICATOR = "small.jpg" - private val YEAR_REGEX = """\d{4}""".toRegex() - - /** - * Singleton of [LivechartAnimeConverter] - * @since 3.1.0 - */ - public val instance: LivechartAnimeConverter by lazy { LivechartAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.livechart + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.normalize +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import kotlinx.coroutines.withContext +import org.apache.commons.text.StringEscapeUtils +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property xmlExtractor Extractor which retrieves the data from raw data. + * @property jsonExtractor Extractor which retrieves the data from raw data. + */ +public class LivechartAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = LivechartConfig, + private val xmlExtractor: DataExtractor = XmlDataExtractor, + private val jsonExtractor: DataExtractor = JsonDataExtractor, +): AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = xmlExtractor.extract(rawContent, mapOf( + "jsonld" to "//script[@type='application/ld+json']/node()", + "title" to "//meta[@property='og:title']/@content", + "image" to "//meta[@property='og:image']/@content", + "episodesDiv" to "//div[text()='Episodes']/../text()", + "episodesCountdown" to "//div[@data-controller='countdown-bar']//div[contains(text(), 'EP')]/text()", + "type" to "//div[text()='Format']/..", + "status" to "//div[text()='Status']/..", + "duration" to "//div[text()='Run time']/..", + "season" to "//div[text()='Season']/../a/text()", + "year" to "//div[text()='Premiere']/following-sibling::*", + "relatedAnime" to "//div[@data-controller='carousel']//article/a/@href", + "tags" to "//div[text()='Tags']/..//a[@data-anime-details-target='tagChip']", + "sourceDiv" to "//div[@data-anime-details-id]/@data-anime-details-id", + "sourceMeta" to "//meta[@property='og:url']/@content", + "studios" to "//a[contains(@href, 'studios')]/text()", + )) + + val jsonld = data.listNotNull<String>("jsonld").first() + val jsonldData = jsonExtractor.extract(jsonld, mapOf( + "url" to "$.url", + "genre" to "$.genre", + "name" to "$.name", + "image" to "$.image", + "numberOfEpisodes" to "$.numberOfEpisodes", + "datePublished" to "$.datePublished", + "alternateName" to "$.alternateName", + "score" to "$.aggregateRating.ratingValue", + "worstRating" to "$.aggregateRating.worstRating", + "bestRating" to "$.aggregateRating.bestRating", + "productionCompany" to "$.productionCompany.*.name", + )) + + val picture = extractPicture(jsonldData, data) + + return@withContext AnimeRaw( + _title = extractTitle(jsonldData, data), + episodes = extractEpisodes(jsonldData, data), + type = extractType(data), + picture = picture, + thumbnail = findThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(jsonldData, data), + _synonyms = extractSynonyms(jsonldData), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(jsonldData, data), + _studios = extractStudios(jsonldData, data), + _producers = hashSetOf(), // not available on livechart + ).addScores(extractScore(jsonldData)) + } + + private fun extractTitle(jsonData: ExtractionResult, data: ExtractionResult): Title { + val name = jsonData.stringOrDefault("name").ifBlank { + data.stringOrDefault("title") + } + + return StringEscapeUtils.unescapeHtml4(name) + } + + private fun extractEpisodes(jsonData: ExtractionResult, data: ExtractionResult): Episodes { + // JSON data + var episodes = jsonData.intOrDefault("numberOfEpisodes") + var isTableValueUnknown = false + + // regular value in data table + if (episodes == 0) { + val episodesValue = data.stringOrDefault("episodesDiv", "?") + .trim() + .split('/') + .last() + + if (episodesValue != "?" && episodesValue != "-") { + episodes = episodesValue.toIntOrNull() ?: 0 + } else { + isTableValueUnknown = true + } + } + + // current episode from table if the anime is ongoing + if (episodes == 0) { + episodes = data.stringOrDefault("episodesCountdown") + .remove("EP") + .trim() + .toIntOrNull() + ?: 0 + + if (episodes != 0 ) { + episodes -= 1 + } + } + + return when { + episodes == 0 && isTableValueUnknown -> 1 + else -> episodes + } + } + + private fun extractType(data: ExtractionResult): AnimeType { + val value = data.string("type") + + return when(value.trim().lowercase()) { + "movie" -> MOVIE + "ova" -> OVA + "special" -> SPECIAL + "tv" -> TV + "tv short" -> TV + "tv special" -> SPECIAL + "web" -> ONA + "web short" -> ONA + "?" -> UNKNOWN_TYPE + "unknown" -> UNKNOWN_TYPE + else -> throw IllegalStateException("Unknown type [$value]") + } + } + + private fun extractPicture(jsonData: ExtractionResult, data: ExtractionResult): URI { + val value = jsonData.stringOrDefault("image").ifBlank { + data.stringOrDefault("image") + } + + return if (!value.endsWith(LARGE_PICTURE_INDICATOR)) { + NO_PICTURE + } else { + URI(value) + } + } + + private fun findThumbnail(uri: URI): URI { + return if (uri == NO_PICTURE) { + NO_PICTURE_THUMBNAIL + } else { + URI(uri.toString().replace(LARGE_PICTURE_INDICATOR, SMALL_PICTURE_INDICATOR)) + } + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val statusString = data.stringOrDefault("status") + + return when (statusString.trim().lowercase()){ + "not yet released" -> UPCOMING + "releasing" -> ONGOING + "finished" -> FINISHED + else -> UNKNOWN_STATUS + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationString = data.stringOrDefault("duration").trim() + + val seconds = """(\d+ ?[aA-zZ]+)+""".toRegex() + .findAll(durationString) + .map { it.value } + .map { + val value = ("""\d+""".toRegex().find(it)?.value?.trim() ?: "0").ifBlank { "0" }.toIntOrNull() ?: 0 + val unit = """[a-z]+""".toRegex().find(it)?.value?.trim()?.lowercase() ?: "" + value to unit + } + .map { + when(it.second) { + "hr", "h" -> it.first * 3600 + "min", "m" -> it.first * 60 + "sec", "s" -> it.first + else -> throw IllegalStateException("Unknown unit [${it.second}]") + } + } + .sum() + + return Duration(seconds, SECONDS) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val splitSeasonString = if (data.notFound("season")) { + listOf(EMPTY) + } else { + data.listNotNull<String>("season").first().remove("Season ").split(' ') + } + + val seasonString = splitSeasonString.first().trim().lowercase() + val season = when(seasonString) { + "winter" -> WINTER + "spring" -> SPRING + "summer" -> SUMMER + "fall" -> FALL + else -> UNDEFINED + } + + val year = if (splitSeasonString.size == 2) { + YEAR_REGEX.find(splitSeasonString[1])?.value?.trim()?.ifBlank { "0" }?.toIntOrNull() ?: 0 + } else { + YEAR_REGEX.find(data.stringOrDefault("year"))?.value?.trim()?.toIntOrNull() ?: 0 + } + + return AnimeSeason( + season = season, + year = year, + ) + } + + private fun extractSourcesEntry(jsonData: ExtractionResult, data: ExtractionResult): HashSet<URI> { + if (!data.notFound("sourceDiv")) { + return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("sourceDiv"))) + } + + if (jsonData.notFound("url")) { + val link = jsonData.string("url").trim().remove("www.").ifBlank { + data.string("sourceMeta").trim().remove("www.") + } + + return hashSetOf(URI(link)) + } + + throw IllegalStateException("Unable to extract source.") + } + + private fun extractSynonyms(jsonData: ExtractionResult): HashSet<Title> { + return jsonData.listNotNull<Title>("alternateName") + .map { StringEscapeUtils.unescapeHtml4(it) } + .toHashSet() + } + + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + return if (data.notFound("relatedAnime")) { + hashSetOf() + } else { + // livechart only shows the franchise without naming the kind of relation + data.listNotNull<String>("relatedAnime") + .map { it.remove("/anime/") } + .map { RelatedAnime(metaDataProviderConfig.buildAnimeLink(it)) } + .toHashSet() + } + } + + private fun extractTags(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { + val tags: HashSet<String> = if (jsonData.notFound("genre")) { + hashSetOf() + } else { + jsonData.listNotNull<Tag>("genre") + .map { it.trim().lowercase() } + .toHashSet() + } + + if (!data.notFound("tags")) { + data.listNotNull<Tag>("tags") + .map { it.trim().lowercase() } + .forEach { tags.add(it) } + } + + return tags + } + + private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { + if (jsonldData.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = jsonldData.double("score") + val from = jsonldData.doubleOrDefault("worstRating", 1.0) + val to = jsonldData.doubleOrDefault("bestRating", 10.0) + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = from..to, + ) + } + + private fun extractStudios(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { + val studios: HashSet<Studio> = if (jsonData.notFound("productionCompany")) { + hashSetOf() + } else { + jsonData.listNotNull<Studio>("productionCompany").toHashSet() + } + + if (!data.notFound("studios")) { + data.listNotNull<Studio>("studios") + .map { it.normalize().lowercase() } + .filterNot { it == "studios" } + .forEach { studios.add(it) } + } + + return studios + } + + public companion object { + private const val LARGE_PICTURE_INDICATOR = "large.jpg" + private const val SMALL_PICTURE_INDICATOR = "small.jpg" + private val YEAR_REGEX = """\d{4}""".toRegex() + + /** + * Singleton of [LivechartAnimeConverter] + * @since 3.1.0 + */ + public val instance: LivechartAnimeConverter by lazy { LivechartAnimeConverter() } + } +} \ No newline at end of file diff --git a/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt b/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt index b7527f4e..2f15645b 100644 --- a/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt +++ b/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt @@ -1,1404 +1,1405 @@ -package io.github.manamiproject.modb.livechart - -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class LivechartAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Alice Gear Aegis: Doki! Actress Darake no Mermaid Grand Prix ♡") - } - } - - @Test - fun `title containing encoded special chars`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/encoded_special_char.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Love Live! School Idol Project: μ's →NEXT LoveLive! 2014 - Endless Parade Encore Animation") - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `no synonyms`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/no_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `one synonym`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/one_synonym.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly("劇場版 少女☆歌劇 レヴュースタァライト ロンド・ロンド・ロンド") - } - } - - @Test - fun `multiple synonyms`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/multiple_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Angels of Death", - "殺戮の天使", - ) - } - } - - @Test - fun `synonym containing encoded special char`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/encoded_special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly("ラブライブ! μ's Final LoveLive! オープニングアニメーション") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isZero() - } - } - - @Test - fun `1 episode`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/1.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/10.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/100.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `number episodes unknown, but currently running`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_unknown_but_currently_running.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1093) - } - } - - @Test - fun `number episodes known and currently running`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_known_and_running.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(12) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 3437`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/sources/3437.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://livechart.me/anime/3437")) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/large.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/small.jpg")) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is TV`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `Unknown is mapped to 'UNKNOWN'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `type 'tv short' is mapped to 'TV'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv_short.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is Movie`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/movie.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type 'web short' is mapped to 'ONA'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web_short.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type 'web' is mapped to 'ONA'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/ova.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is Special`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hr`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 hr 11 min`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour_11_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) - } - } - - @Test - fun `2 hr`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `2 hr 15 min`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours_15_minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) - } - } - - @Test - fun `10 min`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/10_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - - @Test - fun `30 sec`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/30_sec.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(30, SECONDS)) - } - } - - @Test - fun `unknown duration`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple tags`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/multiple_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "comedy", - "fantasy", - "mythology", - "romantic subtext", - "supernatural", - "workplace", - "sequel", - ) - } - } - - @Test - fun `extract exactly one tag`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/one_tag.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly("cgi") - } - } - - @Test - fun `no tags available`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/no_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @Test - fun `season is 'UNDEFINED'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/undefined.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'FALL'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/fall.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'SPRING'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/spring.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'SUMMER'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/summer.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'WINTER'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/winter.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @Test - fun `season is 'UNDEFINED' because there is no element containing season info`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/no_season_element.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `season set completely`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_set.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `season is not set, but premiere`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_set.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `both season and premiere are not set`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_not_set.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `status is ongoing`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/ongoing.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `status is upcoming`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/no_yet_released.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `status is finished`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/finished.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no related anime`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/no_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `one related anime`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/one_relation.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactly( - URI("https://livechart.me/anime/3808"), - ) - } - } - - @Test - fun `multiple related anime no scrolling`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_no_scrolling.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://livechart.me/anime/3367"), - URI("https://livechart.me/anime/3369"), - URI("https://livechart.me/anime/3368"), - ) - } - } - - @Test - fun `multiple related anime with scrolling`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_with_scrolling.html") - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://livechart.me/anime/3662"), - URI("https://livechart.me/anime/6221"), - URI("https://livechart.me/anime/3767"), - URI("https://livechart.me/anime/7353"), - URI("https://livechart.me/anime/3782"), - URI("https://livechart.me/anime/7133"), - URI("https://livechart.me/anime/4823"), - URI("https://livechart.me/anime/7289"), - URI("https://livechart.me/anime/6604"), - URI("https://livechart.me/anime/5530"), - URI("https://livechart.me/anime/3984"), - URI("https://livechart.me/anime/5793"), - URI("https://livechart.me/anime/7156"), - URI("https://livechart.me/anime/4054"), - URI("https://livechart.me/anime/6022"), - ) - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - // when - val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/score.html")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.66) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - // when - val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/no-score.html")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/multiple_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/no_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = LivechartAnimeConverter.instance - - // when - val result = LivechartAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(LivechartAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.livechart + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class LivechartAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Alice Gear Aegis: Doki! Actress Darake no Mermaid Grand Prix ♡") + } + } + + @Test + fun `title containing encoded special chars`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/encoded_special_char.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Love Live! School Idol Project: μ's →NEXT LoveLive! 2014 - Endless Parade Encore Animation") + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `no synonyms`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/no_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `one synonym`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/one_synonym.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly("劇場版 少女☆歌劇 レヴュースタァライト ロンド・ロンド・ロンド") + } + } + + @Test + fun `multiple synonyms`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/multiple_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Angels of Death", + "殺戮の天使", + ) + } + } + + @Test + fun `synonym containing encoded special char`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/encoded_special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly("ラブライブ! μ's Final LoveLive! オープニングアニメーション") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isZero() + } + } + + @Test + fun `1 episode`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/1.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/10.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/100.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `number episodes unknown, but currently running`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_unknown_but_currently_running.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1093) + } + } + + @Test + fun `number episodes known and currently running`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_known_and_running.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(12) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 3437`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/sources/3437.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://livechart.me/anime/3437")) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/large.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/small.jpg")) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is TV`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `Unknown is mapped to 'UNKNOWN'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `type 'tv short' is mapped to 'TV'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv_short.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is Movie`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/movie.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type 'web short' is mapped to 'ONA'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web_short.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type 'web' is mapped to 'ONA'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/ova.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is Special`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hr`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 hr 11 min`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour_11_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) + } + } + + @Test + fun `2 hr`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `2 hr 15 min`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours_15_minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) + } + } + + @Test + fun `10 min`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/10_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + + @Test + fun `30 sec`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/30_sec.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(30, SECONDS)) + } + } + + @Test + fun `unknown duration`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple tags`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/multiple_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "comedy", + "fantasy", + "mythology", + "romantic subtext", + "supernatural", + "workplace", + "sequel", + ) + } + } + + @Test + fun `extract exactly one tag`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/one_tag.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly("cgi") + } + } + + @Test + fun `no tags available`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/no_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @Test + fun `season is 'UNDEFINED'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/undefined.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'FALL'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/fall.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'SPRING'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/spring.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'SUMMER'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/summer.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'WINTER'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/winter.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @Test + fun `season is 'UNDEFINED' because there is no element containing season info`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/no_season_element.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `season set completely`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_set.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `season is not set, but premiere`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_set.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `both season and premiere are not set`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_not_set.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `status is ongoing`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/ongoing.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `status is upcoming`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/no_yet_released.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `status is finished`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/finished.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no related anime`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/no_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `one related anime`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/one_relation.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://livechart.me/anime/3808")), + ) + } + } + + @Test + fun `multiple related anime no scrolling`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_no_scrolling.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://livechart.me/anime/3367")), + RelatedAnime(URI("https://livechart.me/anime/3369")), + RelatedAnime(URI("https://livechart.me/anime/3368")), + ) + } + } + + @Test + fun `multiple related anime with scrolling`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_with_scrolling.html") + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://livechart.me/anime/3662")), + RelatedAnime(URI("https://livechart.me/anime/6221")), + RelatedAnime(URI("https://livechart.me/anime/3767")), + RelatedAnime(URI("https://livechart.me/anime/7353")), + RelatedAnime(URI("https://livechart.me/anime/3782")), + RelatedAnime(URI("https://livechart.me/anime/7133")), + RelatedAnime(URI("https://livechart.me/anime/4823")), + RelatedAnime(URI("https://livechart.me/anime/7289")), + RelatedAnime(URI("https://livechart.me/anime/6604")), + RelatedAnime(URI("https://livechart.me/anime/5530")), + RelatedAnime(URI("https://livechart.me/anime/3984")), + RelatedAnime(URI("https://livechart.me/anime/5793")), + RelatedAnime(URI("https://livechart.me/anime/7156")), + RelatedAnime(URI("https://livechart.me/anime/4054")), + RelatedAnime(URI("https://livechart.me/anime/6022")), + ) + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + // when + val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/score.html")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.66) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + // when + val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/no-score.html")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/multiple_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/no_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = LivechartAnimeConverter.instance + + // when + val result = LivechartAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(LivechartAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt b/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt index 58ff1a52..b1300eef 100644 --- a/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt +++ b/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt @@ -1,311 +1,323 @@ -package io.github.manamiproject.modb.myanimelist - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import io.github.manamiproject.modb.core.logging.LoggerDelegate -import kotlinx.coroutines.withContext -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML from the mobile site version. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extracts specific data from raw content. - */ -public class MyanimelistAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = MyanimelistConfig, - private val extractor: DataExtractor = XmlDataExtractor, -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract(rawContent, mapOf( - "title" to "//meta[@property='og:title']/@content", - "episodes" to "//td[text()='Episodes']/following-sibling::td/a/text()", - "source" to "//meta[@property='og:url']/@content", - "status" to "//td[text()='Status']/following-sibling::*/text()", - "tags" to "//span[@itemprop='genre']/text()", - "type" to "//td[text()='Type']/following-sibling::*/text()", - "duration" to "//td[text()='Duration']/following-sibling::*/text()", - "premiered" to "//td[text()='Premiered']/following-sibling::*/text()", - "aired" to "//td[text()='Aired']/following-sibling::*/text()", - "picture" to "//div[contains(@class, 'status-block')]/div[@itemprop='image']/@content", - "relatedAnime" to "//table[contains(@class, 'entries-table')]//a/@href", - "relatedAnimeDetails" to "//div[contains(@class, 'anime-detail-related-entries')]//a/@href", - "synonyms" to "//h2[text()='Information']/following-sibling::*//tr[0]/td[1]", - "score" to "//span[@itemprop='ratingValue']/span/text()", - "studios" to "//td[text()='Studios']/following-sibling::td/a/text()", - "producers" to "//td[text()='Producers']/following-sibling::td/a/text()", - )) - - val picture = extractPicture(data) - val title = extractTitle(data) - - return@withContext AnimeRaw( - _title = title, - episodes = extractEpisodes(data), - type = extractType(data), - picture = picture, - thumbnail = findThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = postProcessSynonyms(title, extractSynonyms(data)), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<String> { - val processedSynonyms = hashSetOf<String>() - - when { - !title.contains(';') -> { - processedSynonyms.addAll( - synonyms.flatMap { it.split("; ") } - .map { it.remove(";") } - .map { it.trim() } - ) - } - title.contains("""[^ ];[^ ]""".toRegex()) or title.endsWith(";") -> { - processedSynonyms.addAll( - synonyms.flatMap { it.split("; ") } - .map { it.trim() } - ) - } - else -> processedSynonyms.addAll(synonyms.map { it.trim() }) - } - - return processedSynonyms - } - - private fun extractTitle(data: ExtractionResult) = data.string("title") - - private fun extractEpisodes(data: ExtractionResult): Int { - return if (data.notFound("episodes")) { - 0 - } else { - val matchResult = """\d+""".toRegex().find(data.string("episodes")) - return matchResult?.value?.toInt() ?: 0 - } - } - - private fun extractType(data: ExtractionResult): AnimeType { - return when(data.string("type").trim().lowercase()) { - "tv" -> TV - "unknown" -> UNKNOWN_TYPE - "movie" -> MOVIE - "ova" -> OVA - "ona" -> ONA - "special" -> SPECIAL - "music" -> SPECIAL - "pv" -> SPECIAL - "cm" -> SPECIAL - "tv special" -> SPECIAL - else -> throw IllegalStateException("Unknown type [${data.string("type")}]") - } - } - - private fun extractPicture(data: ExtractionResult): URI { - val text = data.string("picture").trim() - - return if (text in setOf("https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon-256.png", "https://cdn.myanimelist.net/images/qm_50.gif")) { - NO_PICTURE - } else { - URI(text) - } - } - - private fun findThumbnail(picture: URI): URI { - return if (NO_PICTURE != picture) { - URI(picture.toString().replace(".jpg", "t.jpg")) - } else { - NO_PICTURE_THUMBNAIL - } - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - return if (data.notFound("synonyms")) { - hashSetOf() - } else { - data.listNotNull<Title>("synonyms").toHashSet() - } - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - val text = data.string("source") - val matchResult = """/\d+/""".toRegex().find(text) - val rawId = matchResult?.value ?: throw IllegalStateException("Unable to extract source") - val id = rawId.trimStart('/').trimEnd('/') - return hashSetOf(metaDataProviderConfig.buildAnimeLink(id)) - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> { - val relatedAnimeDetails = if (data.notFound("relatedAnimeDetails")) { - hashSetOf() - } else { - data.listNotNull<String>("relatedAnimeDetails") - .filter { it.trim().startsWith(metaDataProviderConfig.buildAnimeLink(EMPTY).toString()) } - .mapNotNull { """\d+""".toRegex().find(it)?.value } - .map { metaDataProviderConfig.buildAnimeLink(it) } - .toHashSet() - } - - val relatedAnime = if (data.notFound("relatedAnime")) { - hashSetOf() - } else { - data.listNotNull<String>("relatedAnime") - .filter { it.trim().startsWith(metaDataProviderConfig.buildAnimeLink(EMPTY).toString()) } - .mapNotNull { """\d+""".toRegex().find(it)?.value } - .map { metaDataProviderConfig.buildAnimeLink(it) } - .toHashSet() - } - - return relatedAnime.union(relatedAnimeDetails).toHashSet() - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - return when(data.string("status").trim().lowercase()) { - "finished airing" -> FINISHED - "currently airing" -> ONGOING - "not yet aired" -> UPCOMING - else -> throw IllegalStateException("Unknown status [${data.string("status")}]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - if (data.notFound("duration")) { - return UNKNOWN_DURATION - } - - val text = data.string("duration").trim() - - val values = """\d+""".toRegex().findAll(text).toList().map { it.value } - val units = """(hr|min|sec)""".toRegex().findAll(text) - .toList() - .map { it.value } - .map { it.trim() } - .map { it.lowercase() } - - if (values.count() != units.count()) { - log.warn { "The amount of values [${values.count()}] does not match the amount of units [${units.count()}]." } - return Duration(0, SECONDS) - } - - val valueUnitPairs = mutableListOf<Pair<Int, String>>() - - for (index in values.indices) { - valueUnitPairs.add(Pair(values[index].toInt(), units[index])) - } - - var durationInSeconds = 0 - - valueUnitPairs.forEach { - durationInSeconds += when (it.second) { - "sec" -> it.first - "min" -> it.first * 60 - "hr" -> it.first * 3600 - else -> throw IllegalStateException("[${it.second} is an unknown unit.") - } - } - - return Duration(durationInSeconds, SECONDS) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val premiered = data.string("premiered").trim() - val aired = data.string("aired").trim() - - val seasonText = """[aA-zZ]+""".toRegex().find(premiered)?.value ?: EMPTY - var season = Season.of(seasonText) - if (season == Season.UNDEFINED) { - season = when("""[aA-zZ]+""".toRegex().find(aired)?.value?.lowercase() ?: EMPTY) { - "jan", "feb", "mar" -> Season.WINTER - "apr", "may", "jun" -> Season.SPRING - "jul", "aug", "sep" -> Season.SUMMER - "oct", "nov", "dec" -> Season.FALL - else -> Season.UNDEFINED - } - } - - val yearPremiered = """\d{4}""".toRegex().find(premiered)?.value?.toInt() ?: 0 - val year = if (yearPremiered != 0) { - yearPremiered - } else { - """\d{4}""".toRegex().findAll(aired).firstOrNull()?.value?.toInt() ?: 0 - } - - return AnimeSeason( - season = season, - year = year - ) - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - return if (data.notFound("tags")) { - hashSetOf() - } else { - data.listNotNull<Tag>("tags") - .filterNot { it == data.string("title") } - .toHashSet() - } - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = 1.0..10.0, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return if (data.notFound("producers")) { - hashSetOf() - } else { - data.listNotNull<Producer>("producers").toHashSet() - } - } - - public companion object { - private val log by LoggerDelegate() - - /** - * Singleton of [MyanimelistAnimeConverter] - * @since 6.1.0 - */ - public val instance: MyanimelistAnimeConverter by lazy { MyanimelistAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.myanimelist + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.WRAP_TABLE_ROW +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import io.github.manamiproject.modb.core.logging.LoggerDelegate +import kotlinx.coroutines.withContext +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML from the mobile site version. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extracts specific data from raw content. + */ +public class MyanimelistAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = MyanimelistConfig, + private val extractor: DataExtractor = XmlDataExtractor, +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract(rawContent, mapOf( + "title" to "//meta[@property='og:title']/@content", + "episodes" to "//td[text()='Episodes']/following-sibling::td/a/text()", + "source" to "//meta[@property='og:url']/@content", + "status" to "//td[text()='Status']/following-sibling::*/text()", + "tags" to "//span[@itemprop='genre']/text()", + "type" to "//td[text()='Type']/following-sibling::*/text()", + "duration" to "//td[text()='Duration']/following-sibling::*/text()", + "premiered" to "//td[text()='Premiered']/following-sibling::*/text()", + "aired" to "//td[text()='Aired']/following-sibling::*/text()", + "picture" to "//div[contains(@class, 'status-block')]/div[@itemprop='image']/@content", + "relatedAnime" to "//table[contains(@class, 'entries-table')]//tr/html()", + "relatedAnimeDetails" to "//div[contains(@class, 'anime-detail-related-entries')]/html()", + "synonyms" to "//h2[text()='Information']/following-sibling::*//tr[0]/td[1]", + "score" to "//span[@itemprop='ratingValue']/span/text()", + "studios" to "//td[text()='Studios']/following-sibling::td/a/text()", + "producers" to "//td[text()='Producers']/following-sibling::td/a/text()", + )) + + val picture = extractPicture(data) + val title = extractTitle(data) + + return@withContext AnimeRaw( + _title = title, + episodes = extractEpisodes(data), + type = extractType(data), + picture = picture, + thumbnail = findThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = postProcessSynonyms(title, extractSynonyms(data)), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<String> { + val processedSynonyms = hashSetOf<String>() + + when { + !title.contains(';') -> { + processedSynonyms.addAll( + synonyms.flatMap { it.split("; ") } + .map { it.remove(";") } + .map { it.trim() } + ) + } + title.contains("""[^ ];[^ ]""".toRegex()) or title.endsWith(";") -> { + processedSynonyms.addAll( + synonyms.flatMap { it.split("; ") } + .map { it.trim() } + ) + } + else -> processedSynonyms.addAll(synonyms.map { it.trim() }) + } + + return processedSynonyms + } + + private fun extractTitle(data: ExtractionResult) = data.string("title") + + private fun extractEpisodes(data: ExtractionResult): Int { + return if (data.notFound("episodes")) { + 0 + } else { + val matchResult = """\d+""".toRegex().find(data.string("episodes")) + return matchResult?.value?.toInt() ?: 0 + } + } + + private fun extractType(data: ExtractionResult): AnimeType { + return when(data.string("type").trim().lowercase()) { + "tv" -> TV + "unknown" -> UNKNOWN_TYPE + "movie" -> MOVIE + "ova" -> OVA + "ona" -> ONA + "special" -> SPECIAL + "music" -> SPECIAL + "pv" -> SPECIAL + "cm" -> SPECIAL + "tv special" -> SPECIAL + else -> throw IllegalStateException("Unknown type [${data.string("type")}]") + } + } + + private fun extractPicture(data: ExtractionResult): URI { + val text = data.string("picture").trim() + + return if (text in setOf("https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon-256.png", "https://cdn.myanimelist.net/images/qm_50.gif")) { + NO_PICTURE + } else { + URI(text) + } + } + + private fun findThumbnail(picture: URI): URI { + return if (NO_PICTURE != picture) { + URI(picture.toString().replace(".jpg", "t.jpg")) + } else { + NO_PICTURE_THUMBNAIL + } + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + return if (data.notFound("synonyms")) { + hashSetOf() + } else { + data.listNotNull<Title>("synonyms").toHashSet() + } + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + val text = data.string("source") + val matchResult = """/\d+/""".toRegex().find(text) + val rawId = matchResult?.value ?: throw IllegalStateException("Unable to extract source") + val id = rawId.trimStart('/').trimEnd('/') + return hashSetOf(metaDataProviderConfig.buildAnimeLink(id)) + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + // tiles: <div class="... anime-detail-related-entries"> ... <a href="..."> ... <div class="text">Sequel</div> + val relatedAnimeDetails = if (data.notFound("relatedAnimeDetails")) { + emptyList() + } else { + extractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnimeDetails"), + labelSelector = "//div[@class='text']/text()", + linkSelector = "//a/@href", + ) + } + + // table: <tr><td class="... fn-grey5 ...">Side Story</td><td><ul class="entries"><li><a href="..."> + val relatedAnime = if (data.notFound("relatedAnime")) { + emptyList() + } else { + extractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnime"), + labelSelector = "//td[contains(@class, 'fn-grey5')]/text()", + linkSelector = "//ul[contains(@class, 'entries')]//a/@href", + wrap = WRAP_TABLE_ROW, + ) + } + + return relatedAnime.union(relatedAnimeDetails) + .flatMap { entry -> entry.links.map { it to entry.label } } + .filter { (link, _) -> link.trim().startsWith(metaDataProviderConfig.buildAnimeLink(EMPTY).toString()) } + .mapNotNull { (link, label) -> + val id = """\d+""".toRegex().find(link)?.value ?: return@mapNotNull null + RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(id), label) + } + .toHashSet() + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + return when(data.string("status").trim().lowercase()) { + "finished airing" -> FINISHED + "currently airing" -> ONGOING + "not yet aired" -> UPCOMING + else -> throw IllegalStateException("Unknown status [${data.string("status")}]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + if (data.notFound("duration")) { + return UNKNOWN_DURATION + } + + val text = data.string("duration").trim() + + val values = """\d+""".toRegex().findAll(text).toList().map { it.value } + val units = """(hr|min|sec)""".toRegex().findAll(text) + .toList() + .map { it.value } + .map { it.trim() } + .map { it.lowercase() } + + if (values.count() != units.count()) { + log.warn { "The amount of values [${values.count()}] does not match the amount of units [${units.count()}]." } + return Duration(0, SECONDS) + } + + val valueUnitPairs = mutableListOf<Pair<Int, String>>() + + for (index in values.indices) { + valueUnitPairs.add(Pair(values[index].toInt(), units[index])) + } + + var durationInSeconds = 0 + + valueUnitPairs.forEach { + durationInSeconds += when (it.second) { + "sec" -> it.first + "min" -> it.first * 60 + "hr" -> it.first * 3600 + else -> throw IllegalStateException("[${it.second} is an unknown unit.") + } + } + + return Duration(durationInSeconds, SECONDS) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val premiered = data.string("premiered").trim() + val aired = data.string("aired").trim() + + val seasonText = """[aA-zZ]+""".toRegex().find(premiered)?.value ?: EMPTY + var season = Season.of(seasonText) + if (season == Season.UNDEFINED) { + season = when("""[aA-zZ]+""".toRegex().find(aired)?.value?.lowercase() ?: EMPTY) { + "jan", "feb", "mar" -> Season.WINTER + "apr", "may", "jun" -> Season.SPRING + "jul", "aug", "sep" -> Season.SUMMER + "oct", "nov", "dec" -> Season.FALL + else -> Season.UNDEFINED + } + } + + val yearPremiered = """\d{4}""".toRegex().find(premiered)?.value?.toInt() ?: 0 + val year = if (yearPremiered != 0) { + yearPremiered + } else { + """\d{4}""".toRegex().findAll(aired).firstOrNull()?.value?.toInt() ?: 0 + } + + return AnimeSeason( + season = season, + year = year + ) + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + return if (data.notFound("tags")) { + hashSetOf() + } else { + data.listNotNull<Tag>("tags") + .filterNot { it == data.string("title") } + .toHashSet() + } + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = 1.0..10.0, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return if (data.notFound("producers")) { + hashSetOf() + } else { + data.listNotNull<Producer>("producers").toHashSet() + } + } + + public companion object { + private val log by LoggerDelegate() + + /** + * Singleton of [MyanimelistAnimeConverter] + * @since 6.1.0 + */ + public val instance: MyanimelistAnimeConverter by lazy { MyanimelistAnimeConverter() } + } +} \ No newline at end of file diff --git a/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt b/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt index 4ef04ced..a5ba68a4 100644 --- a/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt +++ b/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt @@ -1,1955 +1,1956 @@ -package io.github.manamiproject.modb.myanimelist - -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class MyanimelistAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") - } - } - - @Test - fun `anime has original and english title in header`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/english_and_original_title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Rurouni Kenshin: Meiji Kenkaku Romantan") - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is TV`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `'Unknown' is mapped to 'UNKNOWN'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `type is Movie`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is music is mapped to special`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is ONA`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ona.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ova.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is Special`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type PV is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/pv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type CM is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/cm.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type 'TV Special' is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv_special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type Music but without a link is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music_without_link.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `movie case which resulted in containsOwn`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie_case_which_resulted_in_containsOwn.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(0) - } - } - - @Test - fun `1 episode`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/10.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/100.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1818.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551t.jpg")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `no synonyms`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/no_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `one synonym`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly("エスパー魔美スペシャル マイエンジェル魔美ちゃん") - } - } - - @Test - fun `multiple languages, one synonym each`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_languages_one_each.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Prétear", - "Prétear: The New Legend of Snow White", - "Shin Shirayuki-hime Densetsu Pretear", - "新白雪姫伝説プリーティア", - ) - } - } - - @Test - fun `synonym containing comma`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/synonym_contains_comma_but_title_does_not.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "'Tis Time for \"Torture,\" Princess", - "It's Time for \"Interrogation\", Princess!", - "Hime-sama \"Goumon\" no Jikan desu", - "姫様“拷問”の時間です", - ) - } - } - - @Test - fun `multiple synonyms for one language`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_for_one_language.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "DB Kai", - "DBK", - "DBZ Kai", - "Dragon Ball Z Kai: The Final Chapters", - "Dragonball Kai", - "ドラゴンボール改", - ) - } - } - - @Test - fun `multiple synonyms with comma in one language section`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_with_comma_in_one_language.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Kono Naka ni Hitori, Imouto ga Iru! Episode 13", - "Kono Naka ni Hitori, Imouto ga Iru! OVA", - "NAKAIMO - My Little Sister Is Among Them! OVA", - "NakaImo, One of Them is My Younger Sister! OVA", - "Who is Imouto? OVA", - "この中に1人、妹がいる!兄、妹、恋人", - ) - } - } - - @Test - fun `one synonym with multiple commas`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym_with_multiple_commas.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Yuru Yuri Special", - "YuruYuri: For Whatever Reason, Never Ending, Heart Throbbing, Pitter-patter, Paradox Eternal", - "ゆるゆり どうして☆止まらない☆トキメキ☆ドキドキ☆パラドクス☆エターナル", - ) - } - } - - @Test - fun `semicolon in synonym wihtout whitespaces`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_wihtout_whitespaces.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "CHAOS;CHILD SILENT SKY", - "Chaos Child Episode 13", - "Chaos Child Episode 14", - "Chaos Child: Silent Sky", - "Chaos;Child, Folge 13 & 14 Silent Sky", - "ChäoS;Child: Silent Sky. Episodios 13 y 14", - "ChäoS;Child: Épisode 13 und 14 Silent Sky", - ) - } - } - - @Test - fun `semicolon in synonym with whitespace`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_with_whitespace.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly( - "Fate/Grand Order: Divine Realm of the Round Table - Camelot Wandering; Agateram", - "劇場版Fate/Grand Order -神聖円卓領域キャメロット- Wandering; Agateram", - ) - } - } - - @Test - fun `semicolon in title but not in synonyms`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_title_but_not_in_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Dual Parallel! Trouble Adventures - Final Frontier", - "Dual! Parallel Lunlun Monogatari Special", - "Dual! Parallel Trouble Adventures Special", - "デュアル!ぱられルンルン物語「ファイナル・フロンティア」", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 16498`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/sources/16498.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://myanimelist.net/anime/16498")) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no adaption, no relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_no_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no adaption, multiple relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_multiple_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://myanimelist.net/anime/17819"), - URI("https://myanimelist.net/anime/19671"), - URI("https://myanimelist.net/anime/22265"), - URI("https://myanimelist.net/anime/30415"), - URI("https://myanimelist.net/anime/33845"), - ) - } - } - - @Test - fun `one adaption, one relation`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactly( - URI("https://myanimelist.net/anime/10165"), - ) - } - } - - @Test - fun `has adaption, multiple relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://myanimelist.net/anime/12685"), - URI("https://myanimelist.net/anime/15197"), - URI("https://myanimelist.net/anime/15199"), - URI("https://myanimelist.net/anime/15201"), - URI("https://myanimelist.net/anime/17277"), - URI("https://myanimelist.net/anime/1953"), - URI("https://myanimelist.net/anime/2124"), - URI("https://myanimelist.net/anime/2904"), - URI("https://myanimelist.net/anime/30711"), - URI("https://myanimelist.net/anime/33155"), - URI("https://myanimelist.net/anime/34438"), - URI("https://myanimelist.net/anime/34439"), - URI("https://myanimelist.net/anime/40334"), - URI("https://myanimelist.net/anime/40836"), - URI("https://myanimelist.net/anime/41075"), - URI("https://myanimelist.net/anime/4596"), - URI("https://myanimelist.net/anime/8888"), - ) - } - } - - @Test - fun `has adaption, no relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_but_no_relation.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'currently airing' is mapped to 'ONGOING'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/ongoing.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'Not yet aired' is mapped to 'UPCOMING'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/upcoming.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'Finished Airing' is mapped to 'FINISHED'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/finished.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple tags`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/multiple_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "adventure", - "drama", - "fantasy", - "military", - "shounen", - ) - } - } - - @Test - fun `extract exactly one tag`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/one_tag.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly("fantasy") - } - } - - @Test - fun `no tags available`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/no_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hr`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 hr 11 min`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) - } - } - - @Test - fun `1 hr 11 min per ep`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min_per_episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) - } - } - - @Test - fun `2 hr`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `2 hr 15 min`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours_15_minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) - } - } - - @Test - fun `10 min`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - - @Test - fun `10 min per ep`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min_per_episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - - @Test - fun `10 sec`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) - } - } - - @Test - fun `10 sec per ep`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec_per_episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) - } - } - - @Test - fun `unknown duration`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @Test - fun `'UNDEFINED', because anime season link is not available`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/undefined.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'FALL'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/fall.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'SPRING'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/spring.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'SUMMER'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/summer.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'WINTER'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/winter.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["apr", "may", "jun"]) - fun `season is 'SPRING' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["jul", "aug", "sep"]) - fun `season is 'SUMMER' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["oct", "nov", "dec"]) - fun `season is 'FALL' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @ValueSource(strings = ["jan", "feb", "mar"]) - fun `season is 'WINTER' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `extract from 'aired', because anime season is not set - year only`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_only.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1928) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - year to unknown`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1996) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - unavailable`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_unavailable.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(0) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day to unknown`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2004) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - year to year`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1964) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - month of year to year`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1992) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - month of year to unknown`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1996) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1980) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day to exact day`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_exact_day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1998) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - year to exact day`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_exact_day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1977) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day to year`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1982) - } - } - - @Test - fun `extract from anime season link which exists in mobile version, but not on desktop version`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/premiered.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2010) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - // when - val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/score.html")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.62) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - // when - val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/no-score.html")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/multiple_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/no_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/multiple_producers.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "movic", - "magic capsule", - "crunchyroll", - "north stars pictures", - "jy animation", - "ca-cygames anime fund", - "mixer", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/no_producers.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = MyanimelistAnimeConverter.instance - - // when - val result = MyanimelistAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(MyanimelistAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.myanimelist + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class MyanimelistAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") + } + } + + @Test + fun `anime has original and english title in header`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/english_and_original_title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Rurouni Kenshin: Meiji Kenkaku Romantan") + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is TV`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `'Unknown' is mapped to 'UNKNOWN'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `type is Movie`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is music is mapped to special`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is ONA`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ona.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ova.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is Special`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type PV is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/pv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type CM is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/cm.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type 'TV Special' is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv_special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type Music but without a link is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music_without_link.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `movie case which resulted in containsOwn`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie_case_which_resulted_in_containsOwn.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(0) + } + } + + @Test + fun `1 episode`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/10.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/100.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1818.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551t.jpg")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `no synonyms`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/no_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `one synonym`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly("エスパー魔美スペシャル マイエンジェル魔美ちゃん") + } + } + + @Test + fun `multiple languages, one synonym each`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_languages_one_each.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Prétear", + "Prétear: The New Legend of Snow White", + "Shin Shirayuki-hime Densetsu Pretear", + "新白雪姫伝説プリーティア", + ) + } + } + + @Test + fun `synonym containing comma`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/synonym_contains_comma_but_title_does_not.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "'Tis Time for \"Torture,\" Princess", + "It's Time for \"Interrogation\", Princess!", + "Hime-sama \"Goumon\" no Jikan desu", + "姫様“拷問”の時間です", + ) + } + } + + @Test + fun `multiple synonyms for one language`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_for_one_language.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "DB Kai", + "DBK", + "DBZ Kai", + "Dragon Ball Z Kai: The Final Chapters", + "Dragonball Kai", + "ドラゴンボール改", + ) + } + } + + @Test + fun `multiple synonyms with comma in one language section`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_with_comma_in_one_language.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Kono Naka ni Hitori, Imouto ga Iru! Episode 13", + "Kono Naka ni Hitori, Imouto ga Iru! OVA", + "NAKAIMO - My Little Sister Is Among Them! OVA", + "NakaImo, One of Them is My Younger Sister! OVA", + "Who is Imouto? OVA", + "この中に1人、妹がいる!兄、妹、恋人", + ) + } + } + + @Test + fun `one synonym with multiple commas`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym_with_multiple_commas.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Yuru Yuri Special", + "YuruYuri: For Whatever Reason, Never Ending, Heart Throbbing, Pitter-patter, Paradox Eternal", + "ゆるゆり どうして☆止まらない☆トキメキ☆ドキドキ☆パラドクス☆エターナル", + ) + } + } + + @Test + fun `semicolon in synonym wihtout whitespaces`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_wihtout_whitespaces.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "CHAOS;CHILD SILENT SKY", + "Chaos Child Episode 13", + "Chaos Child Episode 14", + "Chaos Child: Silent Sky", + "Chaos;Child, Folge 13 & 14 Silent Sky", + "ChäoS;Child: Silent Sky. Episodios 13 y 14", + "ChäoS;Child: Épisode 13 und 14 Silent Sky", + ) + } + } + + @Test + fun `semicolon in synonym with whitespace`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_with_whitespace.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly( + "Fate/Grand Order: Divine Realm of the Round Table - Camelot Wandering; Agateram", + "劇場版Fate/Grand Order -神聖円卓領域キャメロット- Wandering; Agateram", + ) + } + } + + @Test + fun `semicolon in title but not in synonyms`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_title_but_not_in_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Dual Parallel! Trouble Adventures - Final Frontier", + "Dual! Parallel Lunlun Monogatari Special", + "Dual! Parallel Trouble Adventures Special", + "デュアル!ぱられルンルン物語「ファイナル・フロンティア」", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 16498`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/sources/16498.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://myanimelist.net/anime/16498")) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no adaption, no relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_no_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no adaption, multiple relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_multiple_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://myanimelist.net/anime/17819"), "Other"), + RelatedAnime(URI("https://myanimelist.net/anime/19671"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/22265"), "Sequel"), + RelatedAnime(URI("https://myanimelist.net/anime/30415"), "Prequel"), + RelatedAnime(URI("https://myanimelist.net/anime/33845"), "Alternative Version"), + ) + } + } + + @Test + fun `one adaption, one relation`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/10165"), "Sequel"), + ) + } + } + + @Test + fun `has adaption, multiple relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://myanimelist.net/anime/12685"), "Spin-Off"), + RelatedAnime(URI("https://myanimelist.net/anime/15197"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/15199"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/15201"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/17277"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/1953"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/2124"), "Summary"), + RelatedAnime(URI("https://myanimelist.net/anime/2904"), "Sequel"), + RelatedAnime(URI("https://myanimelist.net/anime/30711"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/33155"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/34438"), "Alternative Version"), + RelatedAnime(URI("https://myanimelist.net/anime/34439"), "Alternative Version"), + RelatedAnime(URI("https://myanimelist.net/anime/40334"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/40836"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/41075"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/4596"), "Summary"), + RelatedAnime(URI("https://myanimelist.net/anime/8888"), "Alternative Setting"), + ) + } + } + + @Test + fun `has adaption, no relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_but_no_relation.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'currently airing' is mapped to 'ONGOING'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/ongoing.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'Not yet aired' is mapped to 'UPCOMING'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/upcoming.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'Finished Airing' is mapped to 'FINISHED'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/finished.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple tags`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/multiple_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "adventure", + "drama", + "fantasy", + "military", + "shounen", + ) + } + } + + @Test + fun `extract exactly one tag`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/one_tag.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly("fantasy") + } + } + + @Test + fun `no tags available`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/no_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hr`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 hr 11 min`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) + } + } + + @Test + fun `1 hr 11 min per ep`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min_per_episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) + } + } + + @Test + fun `2 hr`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `2 hr 15 min`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours_15_minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) + } + } + + @Test + fun `10 min`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + + @Test + fun `10 min per ep`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min_per_episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + + @Test + fun `10 sec`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) + } + } + + @Test + fun `10 sec per ep`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec_per_episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) + } + } + + @Test + fun `unknown duration`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @Test + fun `'UNDEFINED', because anime season link is not available`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/undefined.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'FALL'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/fall.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'SPRING'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/spring.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'SUMMER'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/summer.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'WINTER'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/winter.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["apr", "may", "jun"]) + fun `season is 'SPRING' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["jul", "aug", "sep"]) + fun `season is 'SUMMER' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["oct", "nov", "dec"]) + fun `season is 'FALL' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @ValueSource(strings = ["jan", "feb", "mar"]) + fun `season is 'WINTER' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `extract from 'aired', because anime season is not set - year only`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_only.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1928) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - year to unknown`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1996) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - unavailable`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_unavailable.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(0) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day to unknown`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2004) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - year to year`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1964) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - month of year to year`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1992) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - month of year to unknown`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1996) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1980) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day to exact day`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_exact_day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1998) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - year to exact day`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_exact_day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1977) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day to year`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1982) + } + } + + @Test + fun `extract from anime season link which exists in mobile version, but not on desktop version`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/premiered.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2010) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + // when + val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/score.html")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.62) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + // when + val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/no-score.html")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/multiple_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/no_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/multiple_producers.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "movic", + "magic capsule", + "crunchyroll", + "north stars pictures", + "jy animation", + "ca-cygames anime fund", + "mixer", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/no_producers.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = MyanimelistAnimeConverter.instance + + // when + val result = MyanimelistAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(MyanimelistAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt b/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt index 2a3e1155..2e417fd7 100644 --- a/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt +++ b/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt @@ -1,1068 +1,1124 @@ -package io.github.manamiproject.modb.serde - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.httpclient.HttpClient -import io.github.manamiproject.modb.core.httpclient.HttpResponse -import io.github.manamiproject.modb.core.httpclient.RequestBody -import io.github.manamiproject.modb.core.httpclient.RetryCase -import io.github.manamiproject.modb.serde.json.deserializer.Deserializer -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import java.io.IOException -import java.io.InputStream -import java.net.URI -import java.net.URL -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal object TestHttpClient: HttpClient { - override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() -} - -internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { - override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() -} - -internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { - private var closed = false - - override fun read(): Int { - if (closed) throw IOException("Stream closed") - return delegate.read() - } - - override fun close() { - closed = true - delegate.close() - } -} - -@Suppress("unused") -internal object TestAnimeRawObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: AnimeRaw - get() = AnimeRaw("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - _synonyms = hashSetOf( - "DN", - "デスノート", - ), - _studios = hashSetOf( - "madhouse", - ), - _producers = hashSetOf( - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap", - ), - _relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - _tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":["https://myanimelist.net/anime/2994"],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Go-toubun no Hanayome *", - _sources = hashSetOf( - URI("https://anilist.co/anime/177191"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - URI("https://anilist.co/anime/131520"), - ), - _tags = hashSetOf( - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anilist.co/anime/177191" - ], - "title": "Go-toubun no Hanayome *", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - } - ], - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://anilist.co/anime/131520" - ], - "tags": [ - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://anilist.co/anime/131520"],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "5-toubun no Hanayome*", - _sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - URI("https://anidb.net/anime/16165"), - URI("https://anilist.co/anime/131520"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie"), - URI("https://animecountdown.com/1577789"), - URI("https://anisearch.com/anime/16091"), - URI("https://kitsu.app/anime/44229"), - URI("https://livechart.me/anime/10488"), - URI("https://livechart.me/anime/11921"), - URI("https://livechart.me/anime/3448"), - URI("https://livechart.me/anime/9428"), - URI("https://myanimelist.net/anime/48548"), - URI("https://simkl.com/anime/1577789"), - ), - _tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anidb.net", - value = 6.63, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "anime-planet.com", - value = 3.845, - range = 0.5..5.0, - ), - MetaDataProviderScoreValue( - hostname = "anisearch.com", - value = 3.9, - range = 0.1..5.0, - ), - MetaDataProviderScoreValue( - hostname = "kitsu.app", - value = 76.95, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "livechart.me", - value = 8.35, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "simkl.com", - value = 7.9, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anidb.net", - "value": 6.63, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "anime-planet.com", - "value": 3.845, - "range": { - "minInclusive": 0.5, - "maxInclusive": 5.0 - } - }, - { - "hostname": "anisearch.com", - "value": 3.9, - "range": { - "minInclusive": 0.1, - "maxInclusive": 5.0 - } - }, - { - "hostname": "kitsu.app", - "value": 76.95, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "livechart.me", - "value": 8.35, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "simkl.com", - "value": 7.9, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://anidb.net/anime/16165", - "https://anilist.co/anime/131520", - "https://anime-planet.com/anime/the-quintessential-quintuplets-movie", - "https://animecountdown.com/1577789", - "https://anisearch.com/anime/16091", - "https://kitsu.app/anime/44229", - "https://livechart.me/anime/10488", - "https://livechart.me/anime/11921", - "https://livechart.me/anime/3448", - "https://livechart.me/anime/9428", - "https://myanimelist.net/anime/48548", - "https://simkl.com/anime/1577789" - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://anidb.net/anime/16165","https://anilist.co/anime/131520","https://anime-planet.com/anime/the-quintessential-quintuplets-movie","https://animecountdown.com/1577789","https://anisearch.com/anime/16091","https://kitsu.app/anime/44229","https://livechart.me/anime/10488","https://livechart.me/anime/11921","https://livechart.me/anime/3448","https://livechart.me/anime/9428","https://myanimelist.net/anime/48548","https://simkl.com/anime/1577789"],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -@Suppress("unused") -internal object TestAnimeObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: Anime - get() = Anime("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "score": null, - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: Anime - get() = Anime( - title = "Death Note", - sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - score = NoScore, - synonyms = hashSetOf( - "DN", - "デスノート", - ), - studios = hashSetOf( - "madhouse", - ), - producers = hashSetOf( - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap", - ), - relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/2994"), - ), - tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "score": null, - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/2994" - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":["https://myanimelist.net/anime/2994"],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://myanimelist.net/anime/58755"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.44, - arithmeticMean = 7.44, - median = 7.44, - ), - synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - relatedAnime = hashSetOf( - URI("https://myanimelist.net/anime/48548"), - ), - tags = hashSetOf( - "comedy", - "harem", - "romance", - "school", - "shounen", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/58755" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.44, - "arithmeticMean": 7.44, - "median": 7.44 - }, - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - "https://myanimelist.net/anime/48548" - ], - "tags": [ - "comedy", - "harem", - "romance", - "school", - "shounen" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":["https://myanimelist.net/anime/48548"],"tags":["comedy","harem","romance","school","shounen"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.616175305477198, - arithmeticMean = 7.624601113172541, - median = 7.7272727272727275, - ), - synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - "tbs", - ), - relatedAnime = hashSetOf( - URI("https://anidb.net/anime/16165"), - URI("https://anilist.co/anime/131520"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie"), - URI("https://animecountdown.com/1577789"), - URI("https://anisearch.com/anime/16091"), - URI("https://kitsu.app/anime/44229"), - URI("https://livechart.me/anime/10488"), - URI("https://livechart.me/anime/11921"), - URI("https://livechart.me/anime/3448"), - URI("https://livechart.me/anime/9428"), - URI("https://myanimelist.net/anime/48548"), - URI("https://simkl.com/anime/1577789"), - ), - tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ) - - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.616175305477198, - "arithmeticMean": 7.624601113172541, - "median": 7.7272727272727275 - }, - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon", - "tbs" - ], - "relatedAnime": [ - "https://anidb.net/anime/16165", - "https://anilist.co/anime/131520", - "https://anime-planet.com/anime/the-quintessential-quintuplets-movie", - "https://animecountdown.com/1577789", - "https://anisearch.com/anime/16091", - "https://kitsu.app/anime/44229", - "https://livechart.me/anime/10488", - "https://livechart.me/anime/11921", - "https://livechart.me/anime/3448", - "https://livechart.me/anime/9428", - "https://myanimelist.net/anime/48548", - "https://simkl.com/anime/1577789" - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":["https://anidb.net/anime/16165","https://anilist.co/anime/131520","https://anime-planet.com/anime/the-quintessential-quintuplets-movie","https://animecountdown.com/1577789","https://anisearch.com/anime/16091","https://kitsu.app/anime/44229","https://livechart.me/anime/10488","https://livechart.me/anime/11921","https://livechart.me/anime/3448","https://livechart.me/anime/9428","https://myanimelist.net/anime/48548","https://simkl.com/anime/1577789"],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} \ No newline at end of file +package io.github.manamiproject.modb.serde + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.httpclient.HttpClient +import io.github.manamiproject.modb.core.httpclient.HttpResponse +import io.github.manamiproject.modb.core.httpclient.RequestBody +import io.github.manamiproject.modb.core.httpclient.RetryCase +import io.github.manamiproject.modb.serde.json.deserializer.Deserializer +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import java.io.IOException +import java.io.InputStream +import java.net.URI +import java.net.URL +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal object TestHttpClient: HttpClient { + override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() +} + +internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { + override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() +} + +internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { + private var closed = false + + override fun read(): Int { + if (closed) throw IOException("Stream closed") + return delegate.read() + } + + override fun close() { + closed = true + delegate.close() + } +} + +@Suppress("unused") +internal object TestAnimeRawObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: AnimeRaw + get() = AnimeRaw("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + _synonyms = hashSetOf( + "DN", + "デスノート", + ), + _studios = hashSetOf( + "madhouse", + ), + _producers = hashSetOf( + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + _tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Go-toubun no Hanayome *", + _sources = hashSetOf( + URI("https://anilist.co/anime/177191"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/131520")), + ), + _tags = hashSetOf( + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anilist.co/anime/177191" + ], + "title": "Go-toubun no Hanayome *", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + } + ], + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anilist.co/anime/131520" + } + ], + "tags": [ + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "5-toubun no Hanayome*", + _sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + _tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anidb.net", + value = 6.63, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "anime-planet.com", + value = 3.845, + range = 0.5..5.0, + ), + MetaDataProviderScoreValue( + hostname = "anisearch.com", + value = 3.9, + range = 0.1..5.0, + ), + MetaDataProviderScoreValue( + hostname = "kitsu.app", + value = 76.95, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "livechart.me", + value = 8.35, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "simkl.com", + value = 7.9, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anidb.net", + "value": 6.63, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "anime-planet.com", + "value": 3.845, + "range": { + "minInclusive": 0.5, + "maxInclusive": 5.0 + } + }, + { + "hostname": "anisearch.com", + "value": 3.9, + "range": { + "minInclusive": 0.1, + "maxInclusive": 5.0 + } + }, + { + "hostname": "kitsu.app", + "value": 76.95, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "livechart.me", + "value": 8.35, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "simkl.com", + "value": 7.9, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +@Suppress("unused") +internal object TestAnimeObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: Anime + get() = Anime("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "score": null, + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: Anime + get() = Anime( + title = "Death Note", + sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + score = NoScore, + synonyms = hashSetOf( + "DN", + "デスノート", + ), + studios = hashSetOf( + "madhouse", + ), + producers = hashSetOf( + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "score": null, + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://myanimelist.net/anime/58755"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.44, + arithmeticMean = 7.44, + median = 7.44, + ), + synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + ), + tags = hashSetOf( + "comedy", + "harem", + "romance", + "school", + "shounen", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/58755" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.44, + "arithmeticMean": 7.44, + "median": 7.44 + }, + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/48548" + } + ], + "tags": [ + "comedy", + "harem", + "romance", + "school", + "shounen" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.616175305477198, + arithmeticMean = 7.624601113172541, + median = 7.7272727272727275, + ), + synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + "tbs", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ) + + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.616175305477198, + "arithmeticMean": 7.624601113172541, + "median": 7.7272727272727275 + }, + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon", + "tbs" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} \ No newline at end of file diff --git a/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt b/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt index d5d766ce..e2df376d 100644 --- a/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt +++ b/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt @@ -1,347 +1,351 @@ -package io.github.manamiproject.modb.simkl - -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.extensions.normalize -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import kotlinx.coroutines.withContext -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extracts specific data from raw content. - */ -public class SimklAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = SimklConfig, - private val extractor: DataExtractor = XmlDataExtractor, - private val clock: Clock = Clock.systemDefaultZone(), -): AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract( - rawContent, mapOf( - "title" to "//img[@id='detailPosterImg']/@alt", - "episodes" to "//span[@class='episode']/text()", - "episodesFallback" to "//td[@class='SimklTVAboutYearCountry']/text()", - "startDate" to "//span[@itemprop='startDate']/text()", - "source" to "//meta[@property='og:url']/@content", - "airDate" to "//strong[contains(text(),'Air')][contains(text(),'Date')]/../following-sibling::td/text()", - "genres" to "//span[@class='TagGenre']/text()", - "subgenres" to "//a[@class='tag ajList']/text()", - "type" to "//strong[text()='Type:']/..//following-sibling::*/text()", - "duration" to "//meta[@property='og:duration']/@content", - "picture" to "//meta[@property='og:image']/@content", - "relatedAnime" to "//detail-related-item[@id='tvdetailrelations']//div[@class='tvdetailrelationsitems']//a/@href", - "synonyms" to "//td[@itemprop='alternateName']/text()", - "score" to "//div[@itemprop='aggregateRating']//span[@class='SimklTVRatingAverage'][@itemprop='ratingValue']/text()", - "bestRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='bestRating']/text()", - "worstRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='worstRating']/text()", - "studios" to "//strong[text()='Studios:']/..//following-sibling::td//a/text()", - "producers" to "//strong[text()='Producers:']/..//following-sibling::td//a/text()", - ) - ) - - val picture = extractPicture(data) - val title = extractTitle(data) - val episodes = extractEpisodes(data) - - return@withContext AnimeRaw( - _title = title, - episodes = episodes, - type = extractType(data), - picture = picture, - thumbnail = extractThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(episodes, data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = postProcessSynonyms(title, extractSynonyms(data)), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun extractTitle(data: ExtractionResult) = data.string("title").trim() - - private fun extractEpisodes(data: ExtractionResult): Int { - var numberOfEpisodes = if (!data.notFound("episodes")) { - val totalEpisodes = data.listNotNull<String>("episodes").firstOrNull { it.contains("episodes total") || it.contains("episode total") } ?: EMPTY - totalEpisodes.remove("episodes total") - .remove("episode total") - .trim().toIntOrNull() ?: 0 - } else { - 0 - } - - val fallback = data.stringOrDefault("episodesFallback") - val episodesRegex = "\\d+ episode".toRegex() - if (numberOfEpisodes == 0 && fallback.contains(episodesRegex)) { - numberOfEpisodes = episodesRegex.find(fallback)?.value - ?.remove("episode") - ?.remove("episodes") - ?.trim() - ?.toIntOrNull() - ?: 0 - } - - if (numberOfEpisodes != 0) { - return numberOfEpisodes - } - - val startDate = data.stringOrDefault("startDate") - - if (startDate.eitherNullOrBlank()) { - return 0 - } - - val instant = Instant.parse(startDate) - val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() - - if (localDate.isBefore(LocalDate.now(clock))) { - return 1 - } - - return 0 - } - - private fun extractType(data: ExtractionResult): AnimeType { - val extractedType = data.stringOrDefault("type").normalize().lowercase() - - return when (extractedType) { - "movie" -> MOVIE - "ova" -> OVA - "ona" -> ONA - "tv" -> TV - "special" -> SPECIAL - "music video" -> SPECIAL - else -> UNKNOWN_TYPE - } - } - - private fun extractPicture(data: ExtractionResult): URI { - return when { - data.notFound("picture") || !data.stringOrDefault("picture").contains("posters") -> NO_PICTURE - else -> URI(data.string("picture") - .trim() - .remove("https://og.simkl.in/image/details/?poster=") - .substringBefore('&') - ) - } - } - - private fun extractThumbnail(picture: URI): URI { - return when { - picture == NO_PICTURE -> NO_PICTURE_THUMBNAIL - else -> picture - } - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val prefix = "Air Date: " - val airDate = if (data.notFound("airDate")) { - EMPTY - } else { - data.stringOrDefault("airDate") - .remove(prefix) - .trim() - .lowercase() - } - val startDateString = data.stringOrDefault("startDate") - - val today = LocalDate.now(clock) - var startDate: LocalDate? = null - if (startDateString.neitherNullNorBlank()) { - val instant = Instant.parse(startDateString) - startDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() - } - - return when { - airDate.endsWith("now") -> ONGOING - startDate?.isAfter(today) ?: false -> UPCOMING - airDate.contains("-") -> { - val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy") - val endDate = LocalDate.parse(airDate.substringAfter('-').trim(), formatter) - - when { - endDate.isBefore(today) -> FINISHED - startDate?.isBefore(today) ?: false && endDate.isAfter(today) -> ONGOING - else -> ONGOING - } - } - airDate.contains(YEAR_REGEX) && !airDate.contains("-") -> { - val year = YEAR_REGEX.find(airDate)?.value?.toInt() ?: 0 - - when { - year == 0 -> UNKNOWN_STATUS - year < today.year -> FINISHED - year == today.year && startDate != null && startDate.isBefore(today) -> FINISHED - else -> ONGOING - } - } - startDate?.isBefore(today) ?: false -> FINISHED - else -> UNKNOWN_STATUS - } - } - - private fun extractDuration(episodes: Int, data: ExtractionResult): Duration { - if (data.notFound("duration") || data.int("duration") == 0 || episodes == 0) { - return UNKNOWN_DURATION - } - - val duration = data.int("duration") / episodes - - return Duration( - value = duration, - unit = SECONDS, - ) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - if (data.notFound("startDate") || data.string("startDate") == NO_START_DATE) { - return AnimeSeason( - season = UNDEFINED, - year = UNKNOWN_YEAR, - ) - } - - val instant = Instant.parse(data.string("startDate")) - val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() - val year = if (localDate.year <= YEAR_OF_THE_FIRST_ANIME) { - YEAR_OF_THE_FIRST_ANIME - } else { - localDate.year - } - - val season = when (localDate.monthValue) { - 1, 2, 3 -> WINTER - 4, 5, 6 -> SPRING - 7, 8, 9 -> SUMMER - 10, 11, 12 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - season = season, - year = year, - ) - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - val source = data.string("source") - val animeLinkPrefix = metaDataProviderConfig.buildAnimeLink(EMPTY).toString() - check(source.startsWith(animeLinkPrefix)) { "Page of [$source] is not an anime." } - - val animeId = source.remove(animeLinkPrefix).substringBefore('/') - - return hashSetOf(metaDataProviderConfig.buildAnimeLink(animeId)) - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<URI> { - if (data.notFound("relatedAnime")) { - return hashSetOf() - } - - val relatedAnimeIds = data.listNotNull<AnimeId>("relatedAnime") { - it.remove("/anime/").substringBefore('/') - } - - return relatedAnimeIds.map { metaDataProviderConfig.buildAnimeLink(it) }.toHashSet() - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<String> { - return data.stringOrDefault("synonyms").split(',').map { it.trim() }.toHashSet() - } - - private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<Title> { - return synonyms.filterNot { it == title }.toHashSet() - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - val genres = when { - data.notFound("genres") -> emptySet() - else -> data.listNotNull<Tag>("genres") { it.trim() } - } - - val subgenres = when { - data.notFound("subgenres") -> emptySet() - else -> data.listNotNull<Tag>("subgenres") { it.trim() } - } - - return genres.union(subgenres).toHashSet() - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - val bestRating = data.double("bestRating") - val worstRating = data.double("worstRating") - - if (rawScore !in worstRating..bestRating) { - return NoMetaDataProviderScore - } - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = worstRating..bestRating, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return if (data.notFound("producers")) { - hashSetOf() - } else { - data.listNotNull<Producer>("producers").toHashSet() - } - } - - public companion object { - private const val NO_START_DATE = "1970-01-01T00:00:00Z" - private val YEAR_REGEX = "\\d{4}".toRegex() - - /** - * Singleton of [SimklAnimeConverter] - * @since 1.0.0 - */ - public val instance: SimklAnimeConverter by lazy { SimklAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.simkl + +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.extensions.normalize +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import kotlinx.coroutines.withContext +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extracts specific data from raw content. + */ +public class SimklAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = SimklConfig, + private val extractor: DataExtractor = XmlDataExtractor, + private val clock: Clock = Clock.systemDefaultZone(), +): AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract( + rawContent, mapOf( + "title" to "//img[@id='detailPosterImg']/@alt", + "episodes" to "//span[@class='episode']/text()", + "episodesFallback" to "//td[@class='SimklTVAboutYearCountry']/text()", + "startDate" to "//span[@itemprop='startDate']/text()", + "source" to "//meta[@property='og:url']/@content", + "airDate" to "//strong[contains(text(),'Air')][contains(text(),'Date')]/../following-sibling::td/text()", + "genres" to "//span[@class='TagGenre']/text()", + "subgenres" to "//a[@class='tag ajList']/text()", + "type" to "//strong[text()='Type:']/..//following-sibling::*/text()", + "duration" to "//meta[@property='og:duration']/@content", + "picture" to "//meta[@property='og:image']/@content", + "relatedAnime" to "//detail-related-item[@id='tvdetailrelations']//div[@class='tvdetailrelationsitems']//a/html()", + "synonyms" to "//td[@itemprop='alternateName']/text()", + "score" to "//div[@itemprop='aggregateRating']//span[@class='SimklTVRatingAverage'][@itemprop='ratingValue']/text()", + "bestRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='bestRating']/text()", + "worstRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='worstRating']/text()", + "studios" to "//strong[text()='Studios:']/..//following-sibling::td//a/text()", + "producers" to "//strong[text()='Producers:']/..//following-sibling::td//a/text()", + ) + ) + + val picture = extractPicture(data) + val title = extractTitle(data) + val episodes = extractEpisodes(data) + + return@withContext AnimeRaw( + _title = title, + episodes = episodes, + type = extractType(data), + picture = picture, + thumbnail = extractThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(episodes, data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = postProcessSynonyms(title, extractSynonyms(data)), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun extractTitle(data: ExtractionResult) = data.string("title").trim() + + private fun extractEpisodes(data: ExtractionResult): Int { + var numberOfEpisodes = if (!data.notFound("episodes")) { + val totalEpisodes = data.listNotNull<String>("episodes").firstOrNull { it.contains("episodes total") || it.contains("episode total") } ?: EMPTY + totalEpisodes.remove("episodes total") + .remove("episode total") + .trim().toIntOrNull() ?: 0 + } else { + 0 + } + + val fallback = data.stringOrDefault("episodesFallback") + val episodesRegex = "\\d+ episode".toRegex() + if (numberOfEpisodes == 0 && fallback.contains(episodesRegex)) { + numberOfEpisodes = episodesRegex.find(fallback)?.value + ?.remove("episode") + ?.remove("episodes") + ?.trim() + ?.toIntOrNull() + ?: 0 + } + + if (numberOfEpisodes != 0) { + return numberOfEpisodes + } + + val startDate = data.stringOrDefault("startDate") + + if (startDate.eitherNullOrBlank()) { + return 0 + } + + val instant = Instant.parse(startDate) + val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() + + if (localDate.isBefore(LocalDate.now(clock))) { + return 1 + } + + return 0 + } + + private fun extractType(data: ExtractionResult): AnimeType { + val extractedType = data.stringOrDefault("type").normalize().lowercase() + + return when (extractedType) { + "movie" -> MOVIE + "ova" -> OVA + "ona" -> ONA + "tv" -> TV + "special" -> SPECIAL + "music video" -> SPECIAL + else -> UNKNOWN_TYPE + } + } + + private fun extractPicture(data: ExtractionResult): URI { + return when { + data.notFound("picture") || !data.stringOrDefault("picture").contains("posters") -> NO_PICTURE + else -> URI(data.string("picture") + .trim() + .remove("https://og.simkl.in/image/details/?poster=") + .substringBefore('&') + ) + } + } + + private fun extractThumbnail(picture: URI): URI { + return when { + picture == NO_PICTURE -> NO_PICTURE_THUMBNAIL + else -> picture + } + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val prefix = "Air Date: " + val airDate = if (data.notFound("airDate")) { + EMPTY + } else { + data.stringOrDefault("airDate") + .remove(prefix) + .trim() + .lowercase() + } + val startDateString = data.stringOrDefault("startDate") + + val today = LocalDate.now(clock) + var startDate: LocalDate? = null + if (startDateString.neitherNullNorBlank()) { + val instant = Instant.parse(startDateString) + startDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() + } + + return when { + airDate.endsWith("now") -> ONGOING + startDate?.isAfter(today) ?: false -> UPCOMING + airDate.contains("-") -> { + val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy") + val endDate = LocalDate.parse(airDate.substringAfter('-').trim(), formatter) + + when { + endDate.isBefore(today) -> FINISHED + startDate?.isBefore(today) ?: false && endDate.isAfter(today) -> ONGOING + else -> ONGOING + } + } + airDate.contains(YEAR_REGEX) && !airDate.contains("-") -> { + val year = YEAR_REGEX.find(airDate)?.value?.toInt() ?: 0 + + when { + year == 0 -> UNKNOWN_STATUS + year < today.year -> FINISHED + year == today.year && startDate != null && startDate.isBefore(today) -> FINISHED + else -> ONGOING + } + } + startDate?.isBefore(today) ?: false -> FINISHED + else -> UNKNOWN_STATUS + } + } + + private fun extractDuration(episodes: Int, data: ExtractionResult): Duration { + if (data.notFound("duration") || data.int("duration") == 0 || episodes == 0) { + return UNKNOWN_DURATION + } + + val duration = data.int("duration") / episodes + + return Duration( + value = duration, + unit = SECONDS, + ) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + if (data.notFound("startDate") || data.string("startDate") == NO_START_DATE) { + return AnimeSeason( + season = UNDEFINED, + year = UNKNOWN_YEAR, + ) + } + + val instant = Instant.parse(data.string("startDate")) + val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() + val year = if (localDate.year <= YEAR_OF_THE_FIRST_ANIME) { + YEAR_OF_THE_FIRST_ANIME + } else { + localDate.year + } + + val season = when (localDate.monthValue) { + 1, 2, 3 -> WINTER + 4, 5, 6 -> SPRING + 7, 8, 9 -> SUMMER + 10, 11, 12 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + season = season, + year = year, + ) + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + val source = data.string("source") + val animeLinkPrefix = metaDataProviderConfig.buildAnimeLink(EMPTY).toString() + check(source.startsWith(animeLinkPrefix)) { "Page of [$source] is not an anime." } + + val animeId = source.remove(animeLinkPrefix).substringBefore('/') + + return hashSetOf(metaDataProviderConfig.buildAnimeLink(animeId)) + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("relatedAnime")) { + return hashSetOf() + } + + // <a href="/anime/37429/..." class="tvdetailrelationsitem ..."> ... <div class="tvdetailrelationsitemimagetype">Alternative Setting</div> + return extractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnime"), + labelSelector = "//div[@class='tvdetailrelationsitemimagetype']/text()", + linkSelector = "//a/@href", + ).flatMap { entry -> entry.links.map { it to entry.label } } + .map { (link, label) -> RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(link.remove("/anime/").substringBefore('/')), label) } + .toHashSet() + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<String> { + return data.stringOrDefault("synonyms").split(',').map { it.trim() }.toHashSet() + } + + private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<Title> { + return synonyms.filterNot { it == title }.toHashSet() + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + val genres = when { + data.notFound("genres") -> emptySet() + else -> data.listNotNull<Tag>("genres") { it.trim() } + } + + val subgenres = when { + data.notFound("subgenres") -> emptySet() + else -> data.listNotNull<Tag>("subgenres") { it.trim() } + } + + return genres.union(subgenres).toHashSet() + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + val bestRating = data.double("bestRating") + val worstRating = data.double("worstRating") + + if (rawScore !in worstRating..bestRating) { + return NoMetaDataProviderScore + } + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = worstRating..bestRating, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return if (data.notFound("producers")) { + hashSetOf() + } else { + data.listNotNull<Producer>("producers").toHashSet() + } + } + + public companion object { + private const val NO_START_DATE = "1970-01-01T00:00:00Z" + private val YEAR_REGEX = "\\d{4}".toRegex() + + /** + * Singleton of [SimklAnimeConverter] + * @since 1.0.0 + */ + public val instance: SimklAnimeConverter by lazy { SimklAnimeConverter() } + } +} \ No newline at end of file diff --git a/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt b/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt index dc41d65f..2fcf17f0 100644 --- a/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt +++ b/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt @@ -1,1177 +1,1177 @@ -package io.github.manamiproject.modb.simkl - -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test - -internal class SimklAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `correctly extracts primary title`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/primary_title.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Fullmetal Alchemist: Brotherhood") - } - } - - @Test - fun `extract title from alt tag, because SimklLoginBgTitle may return other text than the title like 'Blue Box'`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/37171.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Umi no Triton") - } - } - - @Test - fun `correctly extracts title including special chars`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/special_chars.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("The Quintessential Quintuplets~") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/unknown.html") - - val converter = SimklAnimeConverter( - metaDataProviderConfig = testSimklConfig, - clock = Clock.fixed(Instant.parse("2025-01-01T16:02:42.00Z"), UTC) - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(0) - } - } - - @Test - fun `1 episode`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/10.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/100.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1818.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - - @Test - fun `2 episodes retrieved from fallback`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/fallback.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(2) - } - } - - @Test - fun `correctly extracts 130 on an ongoing anime whose last episode was 162 and next is 163`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/130_episodes_while_ongoing.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(164) - } - } - - @Test - fun `correctly extracts episodes for a series that ended and is missing the info in the header`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/series_ended_missing_info.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is Movie`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/movie.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ova.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is TV`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/tv.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is ONA`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ona.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is SPECIAL`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/special.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'Music Video' is mapped to SPECIAL`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/music_video.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail available`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(NO_PICTURE) - assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) - assertThat(result.thumbnail).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `correctly extracts ONGOING anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/ongoing.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `correctly extracts UPCOMING anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/upcoming.html") - - val converter = SimklAnimeConverter( - metaDataProviderConfig = testSimklConfig, - clock = Clock.fixed(Instant.parse("2020-05-01T16:02:42.00Z"), UTC), - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `correctly extracts FINISHED anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/finished.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `airdate in the past is mapped to FINISHED`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/airdate-year-only-indicating-finished.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @ParameterizedTest - @ValueSource(ints = [1, 2, 3]) - fun `season is 'WINTER'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @ValueSource(ints = [4, 5, 6]) - fun `season is 'SPRING'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(ints = [7, 8, 9]) - fun `season is 'SUMMER'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(ints = [10, 11, 12]) - fun `season is 'FALL'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `correctly extract the year of premiere if start and end year are the same`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/same_year.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `correctly extract the year of premiere`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/overlapping_years.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2009) - } - } - - @Test - fun `correctly adjusts the year of premiere of it is set in year before the first official anime release`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/release-prior-first-ever-anime.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(YEAR_OF_THE_FIRST_ANIME) - } - } - } - - @Test - fun `both unknown`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/unknown.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - assertThat(result.animeSeason.year).isEqualTo(UNKNOWN_YEAR) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `correctly extracts id 41586`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/41586.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactlyInAnyOrder( - URI("https://simkl.com/anime/41586"), - ) - } - } - - @Test - fun `refuses a page which is no longer in the anime section`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/38399_movie.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = exceptionExpected<IllegalStateException> { - converter.convert(testFile) - } - - // then - assertThat(result).hasMessage("Page of [https://simkl.com/movies/38399/drums-of-fu-manchu] is not an anime.") - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `correctly extracts both genres and subgenres`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/tags/genres_and_subgenres.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "colour coded", - "comedy", - "harem", - "high school", - "japanese production", - "male protagonist", - "predominantly female cast", - "romance", - "school", - "school life", - "shounen", - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `correctly extracts both directly and indirectly related anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/relatedAnime/directly_and_indirectly_related.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - URI("https://simkl.com/anime/37429"), - URI("https://simkl.com/anime/38317"), - URI("https://simkl.com/anime/39885"), - ) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `correctly extracts multiple synoynms`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/multiple_synonyms.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "鋼の錬金術師 FULLMETAL ALCHEMIST", - "Fullmetal Alchemist: Brotherhood", - "鋼の錬金術師", - ) - } - } - - @Test - fun `no synonyms`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/no_synonyms.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `correctly extracts minutes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/minutes.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration( - value = 26, - MINUTES, - )) - } - } - - @Test - fun `correctly extracts hours`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/hours.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration( - value = 7800, - SECONDS, - )) - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully extracts score`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val converter = SimklAnimeConverter(testSimklConfig) - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.7) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val converter = SimklAnimeConverter(testAnidbConfig) - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/no-score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - - @Test - fun `returns NoMetaDataProviderScore if the score is outside of the rating range`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val converter = SimklAnimeConverter(testSimklConfig) - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/out_of_range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/multiple_studios.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/no_studios.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/multiple_producers.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "movic", - "crunchyroll", - "north stars pictures", - "jy animation", - "ca-cygames anime fund", - "mixer", - "magic capsule", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/no_producers.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = SimklAnimeConverter.instance - - // when - val result = SimklAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(SimklAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.simkl + +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test + +internal class SimklAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `correctly extracts primary title`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/primary_title.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Fullmetal Alchemist: Brotherhood") + } + } + + @Test + fun `extract title from alt tag, because SimklLoginBgTitle may return other text than the title like 'Blue Box'`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/37171.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Umi no Triton") + } + } + + @Test + fun `correctly extracts title including special chars`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/special_chars.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("The Quintessential Quintuplets~") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/unknown.html") + + val converter = SimklAnimeConverter( + metaDataProviderConfig = testSimklConfig, + clock = Clock.fixed(Instant.parse("2025-01-01T16:02:42.00Z"), UTC) + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(0) + } + } + + @Test + fun `1 episode`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/10.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/100.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1818.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + + @Test + fun `2 episodes retrieved from fallback`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/fallback.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(2) + } + } + + @Test + fun `correctly extracts 130 on an ongoing anime whose last episode was 162 and next is 163`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/130_episodes_while_ongoing.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(164) + } + } + + @Test + fun `correctly extracts episodes for a series that ended and is missing the info in the header`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/series_ended_missing_info.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is Movie`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/movie.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ova.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is TV`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/tv.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is ONA`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ona.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is SPECIAL`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/special.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'Music Video' is mapped to SPECIAL`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/music_video.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail available`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(NO_PICTURE) + assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) + assertThat(result.thumbnail).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `correctly extracts ONGOING anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/ongoing.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `correctly extracts UPCOMING anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/upcoming.html") + + val converter = SimklAnimeConverter( + metaDataProviderConfig = testSimklConfig, + clock = Clock.fixed(Instant.parse("2020-05-01T16:02:42.00Z"), UTC), + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `correctly extracts FINISHED anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/finished.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `airdate in the past is mapped to FINISHED`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/airdate-year-only-indicating-finished.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @ParameterizedTest + @ValueSource(ints = [1, 2, 3]) + fun `season is 'WINTER'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @ValueSource(ints = [4, 5, 6]) + fun `season is 'SPRING'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(ints = [7, 8, 9]) + fun `season is 'SUMMER'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(ints = [10, 11, 12]) + fun `season is 'FALL'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `correctly extract the year of premiere if start and end year are the same`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/same_year.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `correctly extract the year of premiere`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/overlapping_years.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2009) + } + } + + @Test + fun `correctly adjusts the year of premiere of it is set in year before the first official anime release`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/release-prior-first-ever-anime.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(YEAR_OF_THE_FIRST_ANIME) + } + } + } + + @Test + fun `both unknown`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/unknown.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + assertThat(result.animeSeason.year).isEqualTo(UNKNOWN_YEAR) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `correctly extracts id 41586`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/41586.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactlyInAnyOrder( + URI("https://simkl.com/anime/41586"), + ) + } + } + + @Test + fun `refuses a page which is no longer in the anime section`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/38399_movie.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = exceptionExpected<IllegalStateException> { + converter.convert(testFile) + } + + // then + assertThat(result).hasMessage("Page of [https://simkl.com/movies/38399/drums-of-fu-manchu] is not an anime.") + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `correctly extracts both genres and subgenres`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/tags/genres_and_subgenres.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "colour coded", + "comedy", + "harem", + "high school", + "japanese production", + "male protagonist", + "predominantly female cast", + "romance", + "school", + "school life", + "shounen", + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `correctly extracts both directly and indirectly related anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/relatedAnime/directly_and_indirectly_related.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://simkl.com/anime/37429"), "Alternative Setting"), + RelatedAnime(URI("https://simkl.com/anime/38317"), "Side story"), + RelatedAnime(URI("https://simkl.com/anime/39885")), + ) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `correctly extracts multiple synoynms`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/multiple_synonyms.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "鋼の錬金術師 FULLMETAL ALCHEMIST", + "Fullmetal Alchemist: Brotherhood", + "鋼の錬金術師", + ) + } + } + + @Test + fun `no synonyms`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/no_synonyms.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `correctly extracts minutes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/minutes.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration( + value = 26, + MINUTES, + )) + } + } + + @Test + fun `correctly extracts hours`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/hours.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration( + value = 7800, + SECONDS, + )) + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully extracts score`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val converter = SimklAnimeConverter(testSimklConfig) + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.7) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val converter = SimklAnimeConverter(testAnidbConfig) + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/no-score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + + @Test + fun `returns NoMetaDataProviderScore if the score is outside of the rating range`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val converter = SimklAnimeConverter(testSimklConfig) + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/out_of_range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/multiple_studios.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/no_studios.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/multiple_producers.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "movic", + "crunchyroll", + "north stars pictures", + "jy animation", + "ca-cygames anime fund", + "mixer", + "magic capsule", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/no_producers.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = SimklAnimeConverter.instance + + // when + val result = SimklAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(SimklAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file From 057ddac5e157bf0124ae253f6f2011118523fe44 Mon Sep 17 00:00:00 2001 From: Christian Gambale <gambale.christian@gmail.com> Date: Fri, 23 Oct 2026 03:17:32 +0200 Subject: [PATCH 2/4] Fix line endings and order-independent related anime assertions --- .../modb/anidb/AnidbAnimeConverterTest.kt | 3582 +-- .../modb/anilist/AnilistAnimeConverter.kt | 440 +- .../modb/anilist/AnilistAnimeConverterTest.kt | 2174 +- .../animeplanet/AnimePlanetAnimeConverter.kt | 642 +- .../AnimePlanetAnimeConverterTest.kt | 2522 +-- .../AnimenewsnetworkAnimeConverter.kt | 926 +- .../AnimenewsnetworkAnimeConverterTest.kt | 3846 ++-- .../modb/anisearch/AnisearchAnimeConverter.kt | 658 +- .../anisearch/AnisearchAnimeConverterTest.kt | 4048 ++-- .../manamiproject/modb/core/anime/AnimeRaw.kt | 1406 +- .../modb/core/extractor/Descendant.kt | 248 +- .../core/extractor/JsoupXPathDataExtractor.kt | 134 +- .../modb/core/json/AnimeAdapter.kt | 438 +- .../modb/core/json/AnimeRawAdapter.kt | 442 +- .../manamiproject/modb/core/json/Json.kt | 236 +- .../manamiproject/modb/core/TestingAssets.kt | 2560 +-- .../modb/core/anime/AnimeRawTest.kt | 18894 ++++++++-------- .../modb/core/extractor/DescendantTest.kt | 1016 +- .../modb/core/json/AnimeRawAdapterTest.kt | 3598 +-- .../modb/core/json/JsonKtTest.kt | 882 +- .../modb/kitsu/KitsuAnimeConverter.kt | 494 +- .../modb/kitsu/KitsuAnimeConverterTest.kt | 2668 +-- .../app/dataset/AnimeCountdownUrlAdder.kt | 44 +- .../modb/app/readme/DefaultReadmeCreator.kt | 1110 +- .../manamiproject/modb/app/TestingAssets.kt | 2622 +-- .../app/dataset/AnimeCountdownUrlAdderTest.kt | 248 +- ...efaultDownloadControlStateSchedulerTest.kt | 646 +- .../DefaultDownloadControlStateUpdaterTest.kt | 1884 +- .../DownloadControlStateEntryTest.kt | 2276 +- .../app/merging/DefaultMergingServiceTest.kt | 1082 +- .../DefaultGoldenRecordAccessorTest.kt | 2212 +- ...efaultMatchingProbabilityCalculatorTest.kt | 1654 +- .../DefaultUnknownRelatedAnimeRemoverTest.kt | 184 +- .../animecountdown-with-zero-entries.md | 798 +- .../expected-default.md | 812 +- .../DefaultReadmeCreatorTest/merge-locks.md | 812 +- .../reviewed-isolated-entries.md | 812 +- .../zero-based-week.md | 812 +- .../modb/livechart/LivechartAnimeConverter.kt | 674 +- .../livechart/LivechartAnimeConverterTest.kt | 2810 +-- .../myanimelist/MyanimelistAnimeConverter.kt | 646 +- .../MyanimelistAnimeConverterTest.kt | 3912 ++-- .../manamiproject/modb/serde/TestingAssets.kt | 2248 +- .../modb/simkl/SimklAnimeConverter.kt | 702 +- .../modb/simkl/SimklAnimeConverterTest.kt | 2354 +- 45 files changed, 41604 insertions(+), 41604 deletions(-) diff --git a/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt b/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt index cfaaaaa5..e3ff9113 100644 --- a/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt +++ b/anidb/src/test/kotlin/io/github/manamiproject/modb/anidb/AnidbAnimeConverterTest.kt @@ -1,1791 +1,1791 @@ -package io.github.manamiproject.modb.anidb - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class AnidbAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/title/special_chars.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo(".hack//G.U. Trilogy") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `1 episode, although more entries are listed - the selector returns null`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/1_but_more_entries.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `1 episode - the selector returns null`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/1.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/10.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/100.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/1818.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is Movie`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/movie.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type 'Music Video' is mapped to 'Special'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/music_video.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type 'Other' is mapped to 'Special'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/other.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/ova.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type 'TV Series' is mapped to 'TV'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/tv_series.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type 'TV Special' is mapped to 'Special'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/tv_special.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type 'Unknown' is mapped to 'UNKNOWN'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `type 'Web' is mapped to 'ONA'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/web.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg-thumb.jpg")) - } - } - - @Test - fun `eu cdn is replaced by default cdn for both picture and thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/eu_cdn_replaced_by_default_cdn.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) - } - } - - @Test - fun `us cdn is replaced by default cdn for both picture and thumbnail`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/us_cdn_replaced_by_default_cdn.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 11221`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/sources/11221.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://anidb.net/anime/11221")) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no relations`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/related_anime/no_related_anime.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `multiple relations`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/related_anime/multiple_related_anime.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://anidb.net/anime/405"), "Sequel"), - RelatedAnime(URI("https://anidb.net/anime/368"), "Side story"), - RelatedAnime(URI("https://anidb.net/anime/2850")), - RelatedAnime(URI("https://anidb.net/anime/4576"), "Prequel"), - RelatedAnime(URI("https://anidb.net/anime/2995")), - RelatedAnime(URI("https://anidb.net/anime/6141"), "Side story"), - RelatedAnime(URI("https://anidb.net/anime/367"), "Side story"), - RelatedAnime(URI("https://anidb.net/anime/2996")), - RelatedAnime(URI("https://anidb.net/anime/6393"), "Side story"), - ) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `gather all possible synonyms from info and titles tab`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/synonyms/all_types.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Bilježnica smrti", - "Caderno da Morte", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "מחברת המוות", - "دفتر الموت", - "دفترچه مرگ", - "دفترچه یادداشت مرگ", - "ديث نوت", - "كـتـاب الـموت", - "مدونة الموت", - "مذكرة المـوت", - "مذكرة الموت", - "موت نوٹ", - "डेथ नोट", - "デスノート", - "死亡笔记", - "데스노트", - ) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `FINISHED by date published`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/date_published.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `ONGOING by date published`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2013-06-13T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/date_published.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `UPCOMING by date published`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2012-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/date_published.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `FINISHED by start to end`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_end.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `ONGOING by start to end`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2006-05-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_end.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `UPCOMING by start to end`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2005-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_end.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `ONGOING by start to unknown`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2023-10-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_unknown.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `UPCOMING by start to unknown`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2022-10-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_unknown.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `Neither time period nor date published is mapped to UNKNOWN`() { - runTest { - // given - val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) - - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/unknown.html") - - val converter = AnidbAnimeConverter( - metaDataProviderConfig = testAnidbConfig, - clock = fixedClock, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple ignorig the link to find similar anime`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/multiple_tags_with_similar.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "contemporary fantasy", - "detective", - "manga", - "shounen", - "thriller", - ) - } - } - - @Test - fun `empty list of no tags are available`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/no_tags.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - - @Test - fun `extract multiple titles if the link to search for similar anime is not present`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/multiple_tags_without_similar.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly( - "new", - "short movie", - ) - } - } - - @Test - fun `extract exactly one tag`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/one_tag.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly( - "new", - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `null results in 0 seconds`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/missing.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `0 minutes - anidb does not provide seconds`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/0_minutes.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, MINUTES)) - } - } - - @Test - fun `1 minute`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/1_minute.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `25 minutes`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/25_minutes.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(25, MINUTES)) - } - } - - @Test - fun `1 hour`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/1_hour.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `2 hours`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/2_hours.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `2017-10-03 - unknown`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-03_-_unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2017) - } - } - - @Test - fun `2019-07-07 - 2019-09-22`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-07-07_-_2019-09-22.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2019) - } - } - - @Test - fun `2019-08-23`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-08-23.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2019) - } - } - - @Test - fun `2017-10-14 - 2020`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-14_-_2020.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2017) - } - } - - @Test - fun `2019-10-05 - 2020-03`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-10-05_-_2020-03.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2019) - } - } - - @Test - fun `2004`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2004.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2004) - } - } - - @Test - fun `1986-06`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/1986-06.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1986) - } - } - - @Test - fun `2020 - unknown`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2020_-_unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2020) - } - } - - @Test - fun `not available`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/not_available.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(0) - } - } - - @Test - fun `unknown date with year - unknown`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/unknown_date_of_year_-_unknown.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2021) - } - } - - @Test - fun `time period but using date published`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/date_published_but_with_time_period.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2014) - } - } - } - - @Nested - inner class SeasonTests { - - @Nested - inner class SeasonCellTests { - - @Test - fun `season is 'spring'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_spring.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'summer'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_summer.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'fall'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_autumn.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'winter'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_winter.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class DatePublishedCellTests { - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_apr", "date_published_cell_may"]) - fun `season is 'spring'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_jul", "date_published_cell_aug", "date_published_cell_sep"]) - fun `season is 'summer'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_oct", "date_published_cell_nov", "date_published_cell_dec"]) - fun `season is 'fall'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @ValueSource(strings = ["date_published_cell_jan", "date_published_cell_feb", "date_published_cell_mar"]) - fun `season is 'winter'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class StartDateCellTests { - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_apr", "start_date_cell_may", "start_date_cell_jun"]) - fun `season is 'spring'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_jul", "start_date_cell_aug", "start_date_cell_sep"]) - fun `season is 'summer'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_oct", "start_date_cell_nov", "start_date_cell_dec"]) - fun `season is 'fall'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @ValueSource(strings = ["start_date_cell_jan", "start_date_cell_feb"]) - fun `season is 'winter'`(file: String) { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = - loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Test - fun `neither startDate nor datePublished exist therefore season is 'undefined'`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/undefined.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully extracts score`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val converter = AnidbAnimeConverter(testAnidbConfig) - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/scores/score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.65) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val converter = AnidbAnimeConverter(testAnidbConfig) - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/scores/no-score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `always empty, because they mix up persons and actual studios`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/sources/11221.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `always empty, because they mix up persons and actual studios and list them under different headings`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnidbConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnidbAnimeConverterTest/sources/11221.html") - - val converter = AnidbAnimeConverter(testAnidbConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = AnidbAnimeConverter.instance - - // when - val result = AnidbAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnidbAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anidb + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class AnidbAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/title/special_chars.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo(".hack//G.U. Trilogy") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `1 episode, although more entries are listed - the selector returns null`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/1_but_more_entries.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `1 episode - the selector returns null`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/1.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/10.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/100.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/1818.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/episodes/unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is Movie`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/movie.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type 'Music Video' is mapped to 'Special'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/music_video.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type 'Other' is mapped to 'Special'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/other.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/ova.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type 'TV Series' is mapped to 'TV'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/tv_series.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type 'TV Special' is mapped to 'Special'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/tv_special.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type 'Unknown' is mapped to 'UNKNOWN'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `type 'Web' is mapped to 'ONA'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/type/web.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/221838.jpg-thumb.jpg")) + } + } + + @Test + fun `eu cdn is replaced by default cdn for both picture and thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/eu_cdn_replaced_by_default_cdn.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) + } + } + + @Test + fun `us cdn is replaced by default cdn for both picture and thumbnail`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/picture_and_thumbnail/us_cdn_replaced_by_default_cdn.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anidb.net/images/main/257581.jpg-thumb.jpg")) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 11221`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/sources/11221.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://anidb.net/anime/11221")) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no relations`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/related_anime/no_related_anime.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `multiple relations`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/related_anime/multiple_related_anime.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anidb.net/anime/405"), "Sequel"), + RelatedAnime(URI("https://anidb.net/anime/368"), "Side story"), + RelatedAnime(URI("https://anidb.net/anime/2850")), + RelatedAnime(URI("https://anidb.net/anime/4576"), "Prequel"), + RelatedAnime(URI("https://anidb.net/anime/2995")), + RelatedAnime(URI("https://anidb.net/anime/6141"), "Side story"), + RelatedAnime(URI("https://anidb.net/anime/367"), "Side story"), + RelatedAnime(URI("https://anidb.net/anime/2996")), + RelatedAnime(URI("https://anidb.net/anime/6393"), "Side story"), + ) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `gather all possible synonyms from info and titles tab`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/synonyms/all_types.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Bilježnica smrti", + "Caderno da Morte", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "מחברת המוות", + "دفتر الموت", + "دفترچه مرگ", + "دفترچه یادداشت مرگ", + "ديث نوت", + "كـتـاب الـموت", + "مدونة الموت", + "مذكرة المـوت", + "مذكرة الموت", + "موت نوٹ", + "डेथ नोट", + "デスノート", + "死亡笔记", + "데스노트", + ) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `FINISHED by date published`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/date_published.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `ONGOING by date published`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2013-06-13T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/date_published.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `UPCOMING by date published`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2012-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/date_published.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `FINISHED by start to end`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_end.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `ONGOING by start to end`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2006-05-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_end.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `UPCOMING by start to end`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2005-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_end.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `ONGOING by start to unknown`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2023-10-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_unknown.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `UPCOMING by start to unknown`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2022-10-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/start_to_unknown.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `Neither time period nor date published is mapped to UNKNOWN`() { + runTest { + // given + val fixedClock = Clock.fixed(Instant.parse("2019-11-17T15:00:00.00Z"), UTC) + + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/status/unknown.html") + + val converter = AnidbAnimeConverter( + metaDataProviderConfig = testAnidbConfig, + clock = fixedClock, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple ignorig the link to find similar anime`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/multiple_tags_with_similar.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "contemporary fantasy", + "detective", + "manga", + "shounen", + "thriller", + ) + } + } + + @Test + fun `empty list of no tags are available`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/no_tags.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + + @Test + fun `extract multiple titles if the link to search for similar anime is not present`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/multiple_tags_without_similar.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly( + "new", + "short movie", + ) + } + } + + @Test + fun `extract exactly one tag`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/tags/one_tag.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly( + "new", + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `null results in 0 seconds`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/missing.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `0 minutes - anidb does not provide seconds`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/0_minutes.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, MINUTES)) + } + } + + @Test + fun `1 minute`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/1_minute.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `25 minutes`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/25_minutes.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(25, MINUTES)) + } + } + + @Test + fun `1 hour`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/1_hour.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `2 hours`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/duration/2_hours.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `2017-10-03 - unknown`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-03_-_unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2017) + } + } + + @Test + fun `2019-07-07 - 2019-09-22`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-07-07_-_2019-09-22.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2019) + } + } + + @Test + fun `2019-08-23`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-08-23.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2019) + } + } + + @Test + fun `2017-10-14 - 2020`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2017-10-14_-_2020.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2017) + } + } + + @Test + fun `2019-10-05 - 2020-03`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2019-10-05_-_2020-03.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2019) + } + } + + @Test + fun `2004`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2004.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2004) + } + } + + @Test + fun `1986-06`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/1986-06.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1986) + } + } + + @Test + fun `2020 - unknown`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/2020_-_unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2020) + } + } + + @Test + fun `not available`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/not_available.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(0) + } + } + + @Test + fun `unknown date with year - unknown`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/unknown_date_of_year_-_unknown.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2021) + } + } + + @Test + fun `time period but using date published`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/year_of_premiere/date_published_but_with_time_period.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2014) + } + } + } + + @Nested + inner class SeasonTests { + + @Nested + inner class SeasonCellTests { + + @Test + fun `season is 'spring'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_spring.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'summer'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_summer.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'fall'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_autumn.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'winter'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/season_cell_winter.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class DatePublishedCellTests { + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_apr", "date_published_cell_may"]) + fun `season is 'spring'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_jul", "date_published_cell_aug", "date_published_cell_sep"]) + fun `season is 'summer'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_oct", "date_published_cell_nov", "date_published_cell_dec"]) + fun `season is 'fall'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @ValueSource(strings = ["date_published_cell_jan", "date_published_cell_feb", "date_published_cell_mar"]) + fun `season is 'winter'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class StartDateCellTests { + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_apr", "start_date_cell_may", "start_date_cell_jun"]) + fun `season is 'spring'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_jul", "start_date_cell_aug", "start_date_cell_sep"]) + fun `season is 'summer'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_oct", "start_date_cell_nov", "start_date_cell_dec"]) + fun `season is 'fall'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @ValueSource(strings = ["start_date_cell_jan", "start_date_cell_feb"]) + fun `season is 'winter'`(file: String) { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = + loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/$file.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Test + fun `neither startDate nor datePublished exist therefore season is 'undefined'`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/anime_season/season/undefined.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully extracts score`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val converter = AnidbAnimeConverter(testAnidbConfig) + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/scores/score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.65) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val converter = AnidbAnimeConverter(testAnidbConfig) + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/scores/no-score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `always empty, because they mix up persons and actual studios`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/sources/11221.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `always empty, because they mix up persons and actual studios and list them under different headings`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnidbConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnidbConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnidbConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnidbAnimeConverterTest/sources/11221.html") + + val converter = AnidbAnimeConverter(testAnidbConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = AnidbAnimeConverter.instance + + // when + val result = AnidbAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnidbAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt b/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt index 2a521f41..924940dc 100644 --- a/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt +++ b/anilist/src/main/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverter.kt @@ -1,220 +1,220 @@ -package io.github.manamiproject.modb.anilist - -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import kotlinx.coroutines.withContext -import java.net.URI - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extractor which retrieves the data from raw data. - */ -public class AnilistAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnilistConfig, - private val extractor: DataExtractor = JsonDataExtractor, -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - - val data: ExtractionResult = extractor.extract(rawContent, mapOf( - "userPreferred" to "$.data.Media.title.userPreferred", - "titles" to "$.data.Media.title", - "episodes" to "$.data.Media.episodes", - "nextAiringEpisode" to "$.data.Media.nextAiringEpisode.episode", - "format" to "$.data.Media.format", - "picture" to "$.data.Media.coverImage.large", - "synonyms" to "$.data.Media.synonyms", - "duration" to "$.data.Media.duration", - "season" to "$.data.Media.season", - "year" to "$.data.Media.seasonYear", - "startYear" to "$.data.Media.startDate.year", - "genres" to "$.data.Media.genres", - "id" to "$.data.Media.id", - "status" to "$.data.Media.status", - "tags" to "$.data.Media.tags.*.name", - "relatedAnime" to "$.data.Media.relations.edges", - "score" to "$.data.Media.meanScore", - "creators" to "$.data.Media.studios.edges", - )) - - return@withContext AnimeRaw( - _title = extractTitle(data), - episodes = extractEpisodes(data), - type = extractType(data), - picture = extractPicture(data), - thumbnail = extractThumbnail(data), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = extractSynonyms(data), - _tags = extractTags(data), - _relatedAnime = extractRelatedAnime(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun extractTitle(data: ExtractionResult): String = data.stringOrDefault("userPreferred") - - private fun extractEpisodes(data: ExtractionResult): Int { - if (!data.notFound("episodes")) { - return data.int("episodes") - } - - return data.intOrDefault("nextAiringEpisode") - } - - private fun extractType(data: ExtractionResult): AnimeType { - if (data.notFound("format")) { - return UNKNOWN_TYPE - } - - return when(data.string("format").trim().uppercase()) { - "TV" -> TV - "TV_SHORT" -> TV - "MOVIE" -> MOVIE - "ONA" -> ONA - "OVA" -> OVA - "SPECIAL" -> SPECIAL - "MUSIC" -> SPECIAL - else -> throw IllegalStateException("Unknown type [${data.string("format")}]") - } - } - - private fun extractPicture(data: ExtractionResult): URI { - return if (data.notFound("picture")) { - NO_PICTURE - } else { - URI(data.string("picture").trim()) - } - } - - private fun extractThumbnail(data: ExtractionResult): URI { - return if (data.notFound("picture")) { - NO_PICTURE_THUMBNAIL - } else { - URI(data.string("picture").trim()) - } - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - return data.listNotNull<LinkedHashMap<String, Title?>>("titles") - .flatMap { it.values } - .union(data.listNotNull<Title>("synonyms")) - .asSequence() - .filterNot { it == data.string("userPreferred") } - .filterNotNull() - .toHashSet() - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { - return data.listNotNull<Map<String, Any>>("relatedAnime") - .mapNotNull { edge -> - val node = edge["node"] as? Map<*, *> ?: return@mapNotNull null - if (node["type"] != "ANIME") return@mapNotNull null - val id = node["id"] ?: return@mapNotNull null - RelatedAnime.of( - uri = metaDataProviderConfig.buildAnimeLink(id.toString().trim()), - relation = edge["relationType"]?.toString(), - ) - } - .toHashSet() - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - if (data.notFound("status")) { - return UNKNOWN_STATUS - } - - return when(data.string("status").trim().uppercase()) { - "FINISHED" -> FINISHED - "RELEASING" -> ONGOING - "NOT_YET_RELEASED" -> UPCOMING - "CANCELLED" -> UNKNOWN_STATUS - else -> throw IllegalStateException("Unknown status [${data.string("status")}]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationInMinutes = data.intOrDefault("duration") - return Duration(durationInMinutes, MINUTES) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val seasonValue = data.stringOrDefault("season").trim() - val season = AnimeSeason.Season.of(seasonValue) - - val year = if (data.notFound("year")) { - data.intOrDefault("startYear") - } else { - data.int("year") - } - - return AnimeSeason( - season = season, - year = year, - ) - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - val genres = data.listNotNull<Tag>("genres") - val tags = data.listNotNull<Tag>("tags") - - return genres.plus(tags).toHashSet() - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = 1.0..100.0, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return data.listNotNull<Map<String, Any>>("creators") - .filter { it["isMain"] as Boolean } - .map { (it["node"] as Map<*, *>)["name"] as Studio } - .toHashSet() - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return data.listNotNull<Map<String, Any>>("creators") - .filterNot { it["isMain"] as Boolean } - .map { (it["node"] as Map<*, *>)["name"] as Producer } - .toHashSet() - } - - public companion object { - /** - * Singleton of [AnilistAnimeConverter] - * @since 6.1.0 - */ - public val instance: AnilistAnimeConverter by lazy { AnilistAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anilist + +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import kotlinx.coroutines.withContext +import java.net.URI + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extractor which retrieves the data from raw data. + */ +public class AnilistAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnilistConfig, + private val extractor: DataExtractor = JsonDataExtractor, +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + + val data: ExtractionResult = extractor.extract(rawContent, mapOf( + "userPreferred" to "$.data.Media.title.userPreferred", + "titles" to "$.data.Media.title", + "episodes" to "$.data.Media.episodes", + "nextAiringEpisode" to "$.data.Media.nextAiringEpisode.episode", + "format" to "$.data.Media.format", + "picture" to "$.data.Media.coverImage.large", + "synonyms" to "$.data.Media.synonyms", + "duration" to "$.data.Media.duration", + "season" to "$.data.Media.season", + "year" to "$.data.Media.seasonYear", + "startYear" to "$.data.Media.startDate.year", + "genres" to "$.data.Media.genres", + "id" to "$.data.Media.id", + "status" to "$.data.Media.status", + "tags" to "$.data.Media.tags.*.name", + "relatedAnime" to "$.data.Media.relations.edges", + "score" to "$.data.Media.meanScore", + "creators" to "$.data.Media.studios.edges", + )) + + return@withContext AnimeRaw( + _title = extractTitle(data), + episodes = extractEpisodes(data), + type = extractType(data), + picture = extractPicture(data), + thumbnail = extractThumbnail(data), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = extractSynonyms(data), + _tags = extractTags(data), + _relatedAnime = extractRelatedAnime(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun extractTitle(data: ExtractionResult): String = data.stringOrDefault("userPreferred") + + private fun extractEpisodes(data: ExtractionResult): Int { + if (!data.notFound("episodes")) { + return data.int("episodes") + } + + return data.intOrDefault("nextAiringEpisode") + } + + private fun extractType(data: ExtractionResult): AnimeType { + if (data.notFound("format")) { + return UNKNOWN_TYPE + } + + return when(data.string("format").trim().uppercase()) { + "TV" -> TV + "TV_SHORT" -> TV + "MOVIE" -> MOVIE + "ONA" -> ONA + "OVA" -> OVA + "SPECIAL" -> SPECIAL + "MUSIC" -> SPECIAL + else -> throw IllegalStateException("Unknown type [${data.string("format")}]") + } + } + + private fun extractPicture(data: ExtractionResult): URI { + return if (data.notFound("picture")) { + NO_PICTURE + } else { + URI(data.string("picture").trim()) + } + } + + private fun extractThumbnail(data: ExtractionResult): URI { + return if (data.notFound("picture")) { + NO_PICTURE_THUMBNAIL + } else { + URI(data.string("picture").trim()) + } + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + return data.listNotNull<LinkedHashMap<String, Title?>>("titles") + .flatMap { it.values } + .union(data.listNotNull<Title>("synonyms")) + .asSequence() + .filterNot { it == data.string("userPreferred") } + .filterNotNull() + .toHashSet() + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) + } + + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + return data.listNotNull<Map<String, Any>>("relatedAnime") + .mapNotNull { edge -> + val node = edge["node"] as? Map<*, *> ?: return@mapNotNull null + if (node["type"] != "ANIME") return@mapNotNull null + val id = node["id"] ?: return@mapNotNull null + RelatedAnime.of( + uri = metaDataProviderConfig.buildAnimeLink(id.toString().trim()), + relation = edge["relationType"]?.toString(), + ) + } + .toHashSet() + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + if (data.notFound("status")) { + return UNKNOWN_STATUS + } + + return when(data.string("status").trim().uppercase()) { + "FINISHED" -> FINISHED + "RELEASING" -> ONGOING + "NOT_YET_RELEASED" -> UPCOMING + "CANCELLED" -> UNKNOWN_STATUS + else -> throw IllegalStateException("Unknown status [${data.string("status")}]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationInMinutes = data.intOrDefault("duration") + return Duration(durationInMinutes, MINUTES) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val seasonValue = data.stringOrDefault("season").trim() + val season = AnimeSeason.Season.of(seasonValue) + + val year = if (data.notFound("year")) { + data.intOrDefault("startYear") + } else { + data.int("year") + } + + return AnimeSeason( + season = season, + year = year, + ) + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + val genres = data.listNotNull<Tag>("genres") + val tags = data.listNotNull<Tag>("tags") + + return genres.plus(tags).toHashSet() + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = 1.0..100.0, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return data.listNotNull<Map<String, Any>>("creators") + .filter { it["isMain"] as Boolean } + .map { (it["node"] as Map<*, *>)["name"] as Studio } + .toHashSet() + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return data.listNotNull<Map<String, Any>>("creators") + .filterNot { it["isMain"] as Boolean } + .map { (it["node"] as Map<*, *>)["name"] as Producer } + .toHashSet() + } + + public companion object { + /** + * Singleton of [AnilistAnimeConverter] + * @since 6.1.0 + */ + public val instance: AnilistAnimeConverter by lazy { AnilistAnimeConverter() } + } +} \ No newline at end of file diff --git a/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt b/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt index 34ba8e89..a81a6b72 100644 --- a/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt +++ b/anilist/src/test/kotlin/io/github/manamiproject/modb/anilist/AnilistAnimeConverterTest.kt @@ -1,1087 +1,1087 @@ -package io.github.manamiproject.modb.anilist - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class AnilistAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/title/special_chars.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is tv`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is tv_short and is mapped to tv`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv_short.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is special`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/special.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is ova`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ova.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is ona`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ona.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is movie`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/movie.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is music is mapped to special`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/music.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is null and is mapped to UNKNOWN`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `fixed number of episodes`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/39.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(39) - } - } - - @Test - fun `neither episodes nor nextairingepisode is set`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/neither_episodes_nor_nextairingepisode_is_set.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(0) - } - } - - @Test - fun `ongoing series for which the value has to be taken from nextairingepisode`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/ongoing.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(1082) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `picture is available, but anilist never provides a thumbnail so the thumbnail is the same as the picture`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_available.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) - } - } - - @Test - fun `picture is unavailable`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_unavailable.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `synonyms taken from titles and synonyms, ignoring null`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/synonyms/synonyms_from_titles_and_synonyms.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Prism Paradise", - "Tobidasu PriPara: Minna de Mezase! Idol\u2606Grand Prix", - "Tobidasu PuriPara: Mi~nna de Mezase! Idol\u2606Grand Prix", - "とびだすプリパラ み~んなでめざせ!アイドル☆グランプリ", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract correct id and build anime link correctly`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/sources/15689.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.sources.first()).isEqualTo(URI("https://anilist.co/anime/15689")) - } - } - } - - @Nested - inner class RelationsTests { - - @Test - fun `no adaption, no relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_no_relations.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no adaption, multiple relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://anilist.co/anime/107298"), "SIDE_STORY"), - RelatedAnime(URI("https://anilist.co/anime/116147"), "SEQUEL"), - RelatedAnime(URI("https://anilist.co/anime/97857"), "PREQUEL"), - ) - } - } - - @Test - fun `one adaption, one relation`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(URI("https://anilist.co/anime/2337"), "ALTERNATIVE") - ) - } - } - - @Test - fun `has adaption, multiple relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://anilist.co/anime/100148"), "ALTERNATIVE"), - RelatedAnime(URI("https://anilist.co/anime/1081"), "SIDE_STORY"), - RelatedAnime(URI("https://anilist.co/anime/1301"), "PREQUEL"), - RelatedAnime(URI("https://anilist.co/anime/1302"), "CHARACTER"), - RelatedAnime(URI("https://anilist.co/anime/1491"), "CHARACTER"), - RelatedAnime(URI("https://anilist.co/anime/1645"), "SEQUEL"), - RelatedAnime(URI("https://anilist.co/anime/1676"), "SIDE_STORY"), - RelatedAnime(URI("https://anilist.co/anime/1706"), "ALTERNATIVE"), - RelatedAnime(URI("https://anilist.co/anime/17269"), "ALTERNATIVE"), - RelatedAnime(URI("https://anilist.co/anime/2202"), "ALTERNATIVE"), - RelatedAnime(URI("https://anilist.co/anime/2203"), "ALTERNATIVE"), - RelatedAnime(URI("https://anilist.co/anime/2470"), "SIDE_STORY"), - ) - } - } - - @Test - fun `has adaption, no relations`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'FINISHED' is mapped to 'FINISHED'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/finished.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `'RELEASING' is mapped to 'ONGOING'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/releasing.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'NOT_YET_RELEASED' is mapped to 'UPCOMING'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/not_yet_released.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'CANCELLED' is mapped to 'UNKNOWN'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/cancelled.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `null is mapped to 'UNKNOWN'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `put names of genres and tags as distinct list into the anime's tags`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/tags/tags.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "adventure", - "amnesia", - "anti-hero", - "crime", - "cult", - "cyberpunk", - "cyborg", - "drama", - "drugs", - "ensemble cast", - "episodic", - "gambling", - "gender bending", - "guns", - "male protagonist", - "martial arts", - "noir", - "philosophy", - "police", - "primarily adult cast", - "sci-fi", - "space", - "tanned skin", - "terrorism", - "tomboy", - "tragedy", - "travel", - "yakuza", - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `duration is not set and therefore 0`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `duration is set to 0`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/0.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `anilist only uses minutes for duration - so this entry although 15 seconds long is set to 1 min`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/min_duration.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `duration of 24 minutes`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/24.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) - } - } - - @Test - fun `duration of 2 hours`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/120.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @Test - fun `season is 'undefined'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'spring'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/spring.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'summer'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/summer.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'fall'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/fall.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'wimter'`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/winter.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `seasonYear is set`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/seasonyear_set.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `year is not set and default is 0`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(0) - } - } - - @Test - fun `year is 2006 - seasonYear is null but start date is set`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_2006.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(2006) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - // when - val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/score.json")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(9.090909090909092) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - // when - val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/no-score.json")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/multiple_studios.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/no_studios.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testAnilistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = - loadTestResource<String>("AnilistAnimeConverterTest/producers/multiple_producers.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "mixer", - "north stars pictures", - "jy animation", - "crunchyroll", - "ca-cygames anime fund", - "movic", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnilistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) - } - val converter = AnilistAnimeConverter(testAnilistConfig) - - val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/producers/no_producers.json") - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = AnilistAnimeConverter.instance - - // when - val result = AnilistAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnilistAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anilist + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class AnilistAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/title/special_chars.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is tv`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is tv_short and is mapped to tv`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/tv_short.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is special`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/special.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is ova`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ova.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is ona`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/ona.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is movie`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/movie.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is music is mapped to special`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/music.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is null and is mapped to UNKNOWN`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/type/null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `fixed number of episodes`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/39.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(39) + } + } + + @Test + fun `neither episodes nor nextairingepisode is set`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/neither_episodes_nor_nextairingepisode_is_set.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(0) + } + } + + @Test + fun `ongoing series for which the value has to be taken from nextairingepisode`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/episodes/ongoing.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(1082) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `picture is available, but anilist never provides a thumbnail so the thumbnail is the same as the picture`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_available.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx2167-ubU2875AFRTH.jpg")) + } + } + + @Test + fun `picture is unavailable`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/picture_and_thumbnail/picture_unavailable.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `synonyms taken from titles and synonyms, ignoring null`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/synonyms/synonyms_from_titles_and_synonyms.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Prism Paradise", + "Tobidasu PriPara: Minna de Mezase! Idol\u2606Grand Prix", + "Tobidasu PuriPara: Mi~nna de Mezase! Idol\u2606Grand Prix", + "とびだすプリパラ み~んなでめざせ!アイドル☆グランプリ", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract correct id and build anime link correctly`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/sources/15689.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.sources.first()).isEqualTo(URI("https://anilist.co/anime/15689")) + } + } + } + + @Nested + inner class RelationsTests { + + @Test + fun `no adaption, no relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_no_relations.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no adaption, multiple relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anilist.co/anime/107298"), "SIDE_STORY"), + RelatedAnime(URI("https://anilist.co/anime/116147"), "SEQUEL"), + RelatedAnime(URI("https://anilist.co/anime/97857"), "PREQUEL"), + ) + } + } + + @Test + fun `one adaption, one relation`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://anilist.co/anime/2337"), "ALTERNATIVE") + ) + } + } + + @Test + fun `has adaption, multiple relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anilist.co/anime/100148"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/1081"), "SIDE_STORY"), + RelatedAnime(URI("https://anilist.co/anime/1301"), "PREQUEL"), + RelatedAnime(URI("https://anilist.co/anime/1302"), "CHARACTER"), + RelatedAnime(URI("https://anilist.co/anime/1491"), "CHARACTER"), + RelatedAnime(URI("https://anilist.co/anime/1645"), "SEQUEL"), + RelatedAnime(URI("https://anilist.co/anime/1676"), "SIDE_STORY"), + RelatedAnime(URI("https://anilist.co/anime/1706"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/17269"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/2202"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/2203"), "ALTERNATIVE"), + RelatedAnime(URI("https://anilist.co/anime/2470"), "SIDE_STORY"), + ) + } + } + + @Test + fun `has adaption, no relations`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'FINISHED' is mapped to 'FINISHED'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/finished.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `'RELEASING' is mapped to 'ONGOING'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/releasing.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'NOT_YET_RELEASED' is mapped to 'UPCOMING'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/not_yet_released.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'CANCELLED' is mapped to 'UNKNOWN'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/cancelled.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `null is mapped to 'UNKNOWN'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/status/null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `put names of genres and tags as distinct list into the anime's tags`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/tags/tags.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "adventure", + "amnesia", + "anti-hero", + "crime", + "cult", + "cyberpunk", + "cyborg", + "drama", + "drugs", + "ensemble cast", + "episodic", + "gambling", + "gender bending", + "guns", + "male protagonist", + "martial arts", + "noir", + "philosophy", + "police", + "primarily adult cast", + "sci-fi", + "space", + "tanned skin", + "terrorism", + "tomboy", + "tragedy", + "travel", + "yakuza", + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `duration is not set and therefore 0`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `duration is set to 0`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/0.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `anilist only uses minutes for duration - so this entry although 15 seconds long is set to 1 min`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/min_duration.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `duration of 24 minutes`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/24.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) + } + } + + @Test + fun `duration of 2 hours`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/duration/120.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @Test + fun `season is 'undefined'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'spring'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/spring.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'summer'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/summer.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'fall'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/fall.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'wimter'`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/winter.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `seasonYear is set`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/seasonyear_set.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `year is not set and default is 0`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_null.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(0) + } + } + + @Test + fun `year is 2006 - seasonYear is null but start date is set`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/anime_season/season_is_null_and_start_date_is_2006.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(2006) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + // when + val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/score.json")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(9.090909090909092) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + // when + val result = converter.convert(loadTestResource("AnilistAnimeConverterTest/scores/no-score.json")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/multiple_studios.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/studios/no_studios.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testAnilistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = + loadTestResource<String>("AnilistAnimeConverterTest/producers/multiple_producers.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "mixer", + "north stars pictures", + "jy animation", + "crunchyroll", + "ca-cygames anime fund", + "movic", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testAnilistConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnilistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnilistConfig.buildAnimeLink(id) + } + val converter = AnilistAnimeConverter(testAnilistConfig) + + val testFileContent = loadTestResource<String>("AnilistAnimeConverterTest/producers/no_producers.json") + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = AnilistAnimeConverter.instance + + // when + val result = AnilistAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnilistAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt b/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt index 17a7d40e..50d87213 100644 --- a/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt +++ b/anime-planet/src/main/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverter.kt @@ -1,321 +1,321 @@ -package io.github.manamiproject.modb.animeplanet - -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.extractLabelledLinks -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import kotlinx.coroutines.withContext -import java.net.URI -import java.time.Clock -import java.time.LocalDate - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property xmlExtractor Extractor which retrieves the data from raw data. - * @property jsonExtractor Extractor which retrieves the data from raw data. - * @property clock Instance of a clock to determine the current date. - */ -public class AnimePlanetAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnimePlanetConfig, - private val xmlExtractor: DataExtractor = XmlDataExtractor, - private val jsonExtractor: DataExtractor = JsonDataExtractor, - private val clock: Clock = Clock.systemUTC(), -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = xmlExtractor.extract(rawContent, mapOf( - "titleH1" to "//h1[@itemprop='name']/text()", - "jsonld" to "//script[@type='application/ld+json']/node()", - "titleMeta" to "//meta[@property='og:title']/@content", - "thumbnail" to "//img[@itemprop='image']/@src", - "typeEpisodesDuration" to "//span[@class='type']/text()", - "iconYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/text()", - "seasonYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/following-sibling::*/text()", - "source" to "//link[@rel='canonical']/@href", - "alternativeTitle" to "//h2[@class='aka']/text()", - "relatedAnime" to "//div[@id='tabs--relations--anime']/div//a/html()", - "tags" to "//div[contains(@class, 'tags')]//a/text()", - "studios" to "//a[contains(@href, '/studios/')]/text()", - )) - - val jsonld = data.string("jsonld") - val jsonldData = jsonExtractor.extract(jsonld, mapOf( - "title" to "$.name", - "alternateName" to "$.alternateName", - "source" to "$.url", - "image" to "$.image", - "datePublished" to "$.datePublished", - "genre" to "$.genre", - "worstRating" to "$.aggregateRating.worstRating", - "bestRating" to "$.aggregateRating.bestRating", - "ratingValue" to "$.aggregateRating.ratingValue", - )) - - val thumbnail = extractThumbnail(jsonldData, data) - - return@withContext AnimeRaw( - _title = extractTitle(jsonldData, data), - episodes = extractEpisodes(data), - type = extractType(data), - picture = extractPicture(thumbnail), - thumbnail = thumbnail, - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(jsonldData, data), - _sources = extractSourcesEntry(jsonldData, data), - _synonyms = extractSynonyms(jsonldData, data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(jsonldData, data), - _studios = extractStudios(data), - _producers = hashSetOf(), // not available on anime-planet - ).addScores(extractScore(jsonldData)) - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val value = data.stringOrDefault("iconYear") - val currentYear = LocalDate.now(clock).year - - // shows only one year - if (REGEX_YEAR.matches(value)) { - val year = value.toInt() - return when { - year > currentYear -> UPCOMING - year < currentYear -> FINISHED - else -> UNKNOWN_STATUS - } - } - - // shows range of two years - if ("""\d{4} - \d{4}""".toRegex().matches(value)) { - val year = REGEX_YEAR.findAll(value).last().value.toInt() - return when { - year > currentYear -> UPCOMING - year < currentYear -> FINISHED - else -> UNKNOWN_STATUS - } - } - - // shows range from year to unknown - if ("""\d{4} - \?""".toRegex().matches(value)) { - val year = REGEX_YEAR.find(value)!!.value.toInt() - return when { - year > currentYear -> UPCOMING - year < currentYear -> ONGOING - else -> UNKNOWN_STATUS - } - } - - // shows only TBA - if (value == "TBA") { - return UPCOMING - } - - throw IllegalStateException("Unknown case for status [$value]") - } - - private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { - var title = data.stringOrDefault("titleH1") - - if (title.contains(TITLE_CONTAINING_AT_CHAR)) { - title = jsonldData.stringOrDefault("title") - } - - if ((title.eitherNullOrBlank() || title.contains(TITLE_CONTAINING_AT_CHAR))) { - title = data.stringOrDefault("titleMeta") - } - - return title - } - - private fun extractEpisodes(data: ExtractionResult): Episodes { - return data.stringOrDefault("typeEpisodesDuration").let { - """\d+""".toRegex().find(it)?.value?.toInt() ?: 0 - } - } - - private fun extractType(data: ExtractionResult): AnimeType { - val textValue = data.string("typeEpisodesDuration").substringBefore('(').lowercase().let { - """([a-z]| )+""".toRegex().find(it)?.value?.trim() ?: EMPTY - } - - return when(textValue) { - "dvd special" -> SPECIAL - "movie" -> MOVIE - "music video" -> SPECIAL - "other" -> SPECIAL - "ova" -> OVA - "tv" -> TV - "tv special" -> SPECIAL - "web" -> ONA - else -> throw IllegalStateException("Unknown type [$textValue]") - } - } - - private fun extractThumbnail(jsonldData: ExtractionResult, data: ExtractionResult): URI { - val textValue = data.stringOrDefault("thumbnail").ifBlank { - jsonldData.stringOrDefault("image") - } - - return if (textValue.neitherNullNorBlank()) { - URI(textValue.substringBefore("?t=")) - } else { - NO_PICTURE - } - } - - private fun extractPicture(thumbnail: URI): URI { - return if (thumbnail != NO_PICTURE) { - URI(thumbnail.toString().replace("""-\d+x\d+""".toRegex(), EMPTY)) - } else { - NO_PICTURE_THUMBNAIL - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationInMinutes = data.string("typeEpisodesDuration").let { - """\d+ min""".toRegex().find(it)?.value?.remove("min")?.trim()?.toInt() ?: 0 - } - - return Duration(durationInMinutes, MINUTES) - } - - private fun extractAnimeSeason(jsonldData: ExtractionResult, data: ExtractionResult): AnimeSeason { - val yearStr = data.stringOrDefault("iconYear").ifBlank { - jsonldData.stringOrDefault("datePublished") - }.trim() - - val year = when { - REGEX_YEAR.matches(yearStr) -> yearStr.toInt() - yearStr.contains("-") -> { - val split = yearStr.split("-").first().trim() - if (REGEX_YEAR.matches(split)) { - split.toInt() - } else { - 0 - } - } - else -> 0 - } - - val season = data.string("seasonYear").trim().split(' ') - - return when { - season.size != 2 -> AnimeSeason(season = UNDEFINED, year = year) - else -> AnimeSeason(season = AnimeSeason.Season.of(season[0]), year = season[1].toInt()) - } - } - - private fun extractSourcesEntry(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<URI> { - val uri = data.stringOrDefault("source").ifBlank { - jsonldData.stringOrDefault("source") - }.trim().remove("www.") - - return hashSetOf(URI(uri)) - } - - private fun extractSynonyms(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Title> { - val heading = data.string("alternativeTitle") - - val alternativeTitles = hashSetOf<String>() - - when { - heading.startsWith(SINGLE_SYNONYM) -> alternativeTitles.add(heading.remove(SINGLE_SYNONYM).trim()) - heading.startsWith(MULTIPLE_SYNONYMS) -> heading.remove(MULTIPLE_SYNONYMS). - split(',') - .map { it.trim() } - .forEach { alternativeTitles.add(it) } - } - - if (alternativeTitles.any { it.contains(TITLE_CONTAINING_AT_CHAR) }) { - alternativeTitles.removeIf { it.contains(TITLE_CONTAINING_AT_CHAR) } - jsonldData.listNotNull<Title>("alternateName").forEach { alternativeTitles.add(it) } - } - - return alternativeTitles - } - - private fun extractTags(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { - val tags = if (jsonldData.notFound("genre")) { - hashSetOf() - } else { - jsonldData.listNotNull<Tag>("genre").map { it.trimEnd(',') }.toHashSet() - } - - if (!data.notFound("tags")) { - data.listNotNull<Tag>("tags").map { it.trimEnd(',') }.forEach { tags.add(it) } - } - - return tags - } - - private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { - if (data.notFound("relatedAnime")) { - return hashSetOf() - } - - // <a href="/anime/..." class="RelatedEntry ..."> ... <span class="RelatedEntry__subtitle ...">Side Story</span> - // The subtitle is optional. - return xmlExtractor.extractLabelledLinks( - snippets = data.listNotNull<String>("relatedAnime"), - labelSelector = "//span[contains(@class, 'RelatedEntry__subtitle')]/text()", - linkSelector = "//a/@href", - ).flatMap { entry -> entry.links.map { RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(it.remove("/anime/")), entry.label) } } - .toHashSet() - } - - private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { - if (jsonldData.notFound("ratingValue")) { - return NoMetaDataProviderScore - } - - val rawScore = jsonldData.double("ratingValue") - val from = jsonldData.doubleOrDefault("worstRating", 0.5) - val to = jsonldData.doubleOrDefault("bestRating", 5.0) - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = from..to, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - public companion object { - private val REGEX_YEAR = """\d{4}""".toRegex() - private const val TITLE_CONTAINING_AT_CHAR= "[email protected]" - private const val SINGLE_SYNONYM = "Alt title:" - private const val MULTIPLE_SYNONYMS = "Alt titles:" - - /** - * Singleton of [AnimePlanetAnimeConverter] - * @since 1.0.0 - */ - public val instance: AnimePlanetAnimeConverter by lazy { AnimePlanetAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.animeplanet + +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import kotlinx.coroutines.withContext +import java.net.URI +import java.time.Clock +import java.time.LocalDate + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property xmlExtractor Extractor which retrieves the data from raw data. + * @property jsonExtractor Extractor which retrieves the data from raw data. + * @property clock Instance of a clock to determine the current date. + */ +public class AnimePlanetAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnimePlanetConfig, + private val xmlExtractor: DataExtractor = XmlDataExtractor, + private val jsonExtractor: DataExtractor = JsonDataExtractor, + private val clock: Clock = Clock.systemUTC(), +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = xmlExtractor.extract(rawContent, mapOf( + "titleH1" to "//h1[@itemprop='name']/text()", + "jsonld" to "//script[@type='application/ld+json']/node()", + "titleMeta" to "//meta[@property='og:title']/@content", + "thumbnail" to "//img[@itemprop='image']/@src", + "typeEpisodesDuration" to "//span[@class='type']/text()", + "iconYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/text()", + "seasonYear" to "//section[@class='pure-g entryBar']//span[@class='iconYear']/following-sibling::*/text()", + "source" to "//link[@rel='canonical']/@href", + "alternativeTitle" to "//h2[@class='aka']/text()", + "relatedAnime" to "//div[@id='tabs--relations--anime']/div//a/html()", + "tags" to "//div[contains(@class, 'tags')]//a/text()", + "studios" to "//a[contains(@href, '/studios/')]/text()", + )) + + val jsonld = data.string("jsonld") + val jsonldData = jsonExtractor.extract(jsonld, mapOf( + "title" to "$.name", + "alternateName" to "$.alternateName", + "source" to "$.url", + "image" to "$.image", + "datePublished" to "$.datePublished", + "genre" to "$.genre", + "worstRating" to "$.aggregateRating.worstRating", + "bestRating" to "$.aggregateRating.bestRating", + "ratingValue" to "$.aggregateRating.ratingValue", + )) + + val thumbnail = extractThumbnail(jsonldData, data) + + return@withContext AnimeRaw( + _title = extractTitle(jsonldData, data), + episodes = extractEpisodes(data), + type = extractType(data), + picture = extractPicture(thumbnail), + thumbnail = thumbnail, + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(jsonldData, data), + _sources = extractSourcesEntry(jsonldData, data), + _synonyms = extractSynonyms(jsonldData, data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(jsonldData, data), + _studios = extractStudios(data), + _producers = hashSetOf(), // not available on anime-planet + ).addScores(extractScore(jsonldData)) + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val value = data.stringOrDefault("iconYear") + val currentYear = LocalDate.now(clock).year + + // shows only one year + if (REGEX_YEAR.matches(value)) { + val year = value.toInt() + return when { + year > currentYear -> UPCOMING + year < currentYear -> FINISHED + else -> UNKNOWN_STATUS + } + } + + // shows range of two years + if ("""\d{4} - \d{4}""".toRegex().matches(value)) { + val year = REGEX_YEAR.findAll(value).last().value.toInt() + return when { + year > currentYear -> UPCOMING + year < currentYear -> FINISHED + else -> UNKNOWN_STATUS + } + } + + // shows range from year to unknown + if ("""\d{4} - \?""".toRegex().matches(value)) { + val year = REGEX_YEAR.find(value)!!.value.toInt() + return when { + year > currentYear -> UPCOMING + year < currentYear -> ONGOING + else -> UNKNOWN_STATUS + } + } + + // shows only TBA + if (value == "TBA") { + return UPCOMING + } + + throw IllegalStateException("Unknown case for status [$value]") + } + + private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { + var title = data.stringOrDefault("titleH1") + + if (title.contains(TITLE_CONTAINING_AT_CHAR)) { + title = jsonldData.stringOrDefault("title") + } + + if ((title.eitherNullOrBlank() || title.contains(TITLE_CONTAINING_AT_CHAR))) { + title = data.stringOrDefault("titleMeta") + } + + return title + } + + private fun extractEpisodes(data: ExtractionResult): Episodes { + return data.stringOrDefault("typeEpisodesDuration").let { + """\d+""".toRegex().find(it)?.value?.toInt() ?: 0 + } + } + + private fun extractType(data: ExtractionResult): AnimeType { + val textValue = data.string("typeEpisodesDuration").substringBefore('(').lowercase().let { + """([a-z]| )+""".toRegex().find(it)?.value?.trim() ?: EMPTY + } + + return when(textValue) { + "dvd special" -> SPECIAL + "movie" -> MOVIE + "music video" -> SPECIAL + "other" -> SPECIAL + "ova" -> OVA + "tv" -> TV + "tv special" -> SPECIAL + "web" -> ONA + else -> throw IllegalStateException("Unknown type [$textValue]") + } + } + + private fun extractThumbnail(jsonldData: ExtractionResult, data: ExtractionResult): URI { + val textValue = data.stringOrDefault("thumbnail").ifBlank { + jsonldData.stringOrDefault("image") + } + + return if (textValue.neitherNullNorBlank()) { + URI(textValue.substringBefore("?t=")) + } else { + NO_PICTURE + } + } + + private fun extractPicture(thumbnail: URI): URI { + return if (thumbnail != NO_PICTURE) { + URI(thumbnail.toString().replace("""-\d+x\d+""".toRegex(), EMPTY)) + } else { + NO_PICTURE_THUMBNAIL + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationInMinutes = data.string("typeEpisodesDuration").let { + """\d+ min""".toRegex().find(it)?.value?.remove("min")?.trim()?.toInt() ?: 0 + } + + return Duration(durationInMinutes, MINUTES) + } + + private fun extractAnimeSeason(jsonldData: ExtractionResult, data: ExtractionResult): AnimeSeason { + val yearStr = data.stringOrDefault("iconYear").ifBlank { + jsonldData.stringOrDefault("datePublished") + }.trim() + + val year = when { + REGEX_YEAR.matches(yearStr) -> yearStr.toInt() + yearStr.contains("-") -> { + val split = yearStr.split("-").first().trim() + if (REGEX_YEAR.matches(split)) { + split.toInt() + } else { + 0 + } + } + else -> 0 + } + + val season = data.string("seasonYear").trim().split(' ') + + return when { + season.size != 2 -> AnimeSeason(season = UNDEFINED, year = year) + else -> AnimeSeason(season = AnimeSeason.Season.of(season[0]), year = season[1].toInt()) + } + } + + private fun extractSourcesEntry(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<URI> { + val uri = data.stringOrDefault("source").ifBlank { + jsonldData.stringOrDefault("source") + }.trim().remove("www.") + + return hashSetOf(URI(uri)) + } + + private fun extractSynonyms(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Title> { + val heading = data.string("alternativeTitle") + + val alternativeTitles = hashSetOf<String>() + + when { + heading.startsWith(SINGLE_SYNONYM) -> alternativeTitles.add(heading.remove(SINGLE_SYNONYM).trim()) + heading.startsWith(MULTIPLE_SYNONYMS) -> heading.remove(MULTIPLE_SYNONYMS). + split(',') + .map { it.trim() } + .forEach { alternativeTitles.add(it) } + } + + if (alternativeTitles.any { it.contains(TITLE_CONTAINING_AT_CHAR) }) { + alternativeTitles.removeIf { it.contains(TITLE_CONTAINING_AT_CHAR) } + jsonldData.listNotNull<Title>("alternateName").forEach { alternativeTitles.add(it) } + } + + return alternativeTitles + } + + private fun extractTags(jsonldData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { + val tags = if (jsonldData.notFound("genre")) { + hashSetOf() + } else { + jsonldData.listNotNull<Tag>("genre").map { it.trimEnd(',') }.toHashSet() + } + + if (!data.notFound("tags")) { + data.listNotNull<Tag>("tags").map { it.trimEnd(',') }.forEach { tags.add(it) } + } + + return tags + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("relatedAnime")) { + return hashSetOf() + } + + // <a href="/anime/..." class="RelatedEntry ..."> ... <span class="RelatedEntry__subtitle ...">Side Story</span> + // The subtitle is optional. + return xmlExtractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnime"), + labelSelector = "//span[contains(@class, 'RelatedEntry__subtitle')]/text()", + linkSelector = "//a/@href", + ).flatMap { entry -> entry.links.map { RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(it.remove("/anime/")), entry.label) } } + .toHashSet() + } + + private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { + if (jsonldData.notFound("ratingValue")) { + return NoMetaDataProviderScore + } + + val rawScore = jsonldData.double("ratingValue") + val from = jsonldData.doubleOrDefault("worstRating", 0.5) + val to = jsonldData.doubleOrDefault("bestRating", 5.0) + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = from..to, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + public companion object { + private val REGEX_YEAR = """\d{4}""".toRegex() + private const val TITLE_CONTAINING_AT_CHAR= "[email protected]" + private const val SINGLE_SYNONYM = "Alt title:" + private const val MULTIPLE_SYNONYMS = "Alt titles:" + + /** + * Singleton of [AnimePlanetAnimeConverter] + * @since 1.0.0 + */ + public val instance: AnimePlanetAnimeConverter by lazy { AnimePlanetAnimeConverter() } + } +} \ No newline at end of file diff --git a/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt b/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt index 48eb7a4b..fffdc25a 100644 --- a/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt +++ b/anime-planet/src/test/kotlin/io/github/manamiproject/modb/animeplanet/AnimePlanetAnimeConverterTest.kt @@ -1,1261 +1,1261 @@ -package io.github.manamiproject.modb.animeplanet - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import java.time.Clock -import java.time.LocalDate -import java.time.ZoneOffset.UTC -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS - -internal class AnimePlanetAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/title/special_chars.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("PriPara Movie: Minna no Akogare♪ Let's Go☆Prix Paris") - } - } - - @Test - fun `extract title from jsonld if it was replaces by 'email protected'`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("Malice@Doll") - } - } - - @Test - fun `extract title from meta tag if it was replaces by 'email protected' and jsonld does not exist`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected_no_jsonld.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("The iDOLM@STER Million Live!") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `1 episode`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1_ep.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `1081 and more episodes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1081+_eps.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(1081) - } - } - - @Test - fun `25 episodes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/25_eps.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(25) - } - } - - @Test - fun `no episodes is mapped to 1 episode`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/no_episodes.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isZero() - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `'web' is mapped to ONA`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/web.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `'TV Special' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv_special.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'TV' is mapped to TV`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `'OVA' is mapped to OVA`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/ova.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `'other' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/other.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'music video' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/music_video.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'movie' is mapped to Movie`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/movie.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `'dvd special' is mapped to Special`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/dvd_special.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `value without braces is extracted correctly`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/no_braces.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `correctly extract picture and thumbnail`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1.jpg") - assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1-190x242.jpg") - } - } - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") - assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") - } - } - - @Test - fun `ensure that the primary screenshot is extracted as picture`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/ensure_primary_screenshot_as_picture.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1.webp") - assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1-285x399.webp") - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'tba' is mapped to UPCOMING`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/tba.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `current year is mapped to UNKNOWN, because it is not possible to determine whether the anime has finished or is still running`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_is_current_year.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `year in the future is mapped to UPCOMING`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_future.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `year in the past is mapped to FINISHED`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_past.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `range starting in the past is mapped to ONGOING`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_in_the_past.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `range starting in the same year is mapped to UNKNOWN, because it is not possible to determine whether the anime has already started or not`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_same_year.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `range ending in the past`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_in_the_past.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `range ending same year`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_same_year.html") - - val converter = AnimePlanetAnimeConverter( - metaDataProviderConfig = testAnimePlanetConfig, - clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hour`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_hour.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 min`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_min.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `2 hours`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `2 hours 15 minutes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours_15_min.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) - } - } - - @Test - fun `10 minutes`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/10_min.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Test - fun `season is undefined, but year is set`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/year_only.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 2016)) - } - } - - @Test - fun `season is summer`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/summer.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(SUMMER, 2020)) - } - } - - @Test - fun `season is spring`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/spring.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(SPRING, 2020)) - } - } - - @Test - fun `season is winter`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/winter.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(WINTER, 2020)) - } - } - - @Test - fun `season is fall`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/fall.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(FALL, 2020)) - } - } - - @Test - fun `tba - no year, no season`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/tba.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 0)) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract url slug as id`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/sources/id.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.sources).containsExactly(URI("https://anime-planet.com/anime/code-geass-lelouch-of-the-rebellion-r2")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `successfully extract the alternative title`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_available.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactly("Kimi no Na wa.") - } - } - - @Test - fun `alternative title is not available`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_not_available.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `extract synonym from jsonld if it was replaces by 'email protected'`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_replaced_by_email_protected.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactly("GJ Club@") - } - } - - @Test - fun `extract multiple synonyms`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/multiple_synonyms.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "\"Space Battleship Yamato\" Era: The Choice in 2202", - "\"Uchuu Senkan Yamato\" to Iu Jidai: Seireki 2202-nen no Sentaku", - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `extract same franchise and other franchise`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = - loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_and_other_franchise.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist"), "Other Franchise"), - RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-4-koma-theater"), "Omake"), - RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-specials"), "Side Story"), - RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-premium-collection"), "Other Franchise"), - RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-reflections"), "Other Franchise"), - RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-the-movie-conqueror-of-shamballa"), "Other Franchise"), - RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-the-sacred-star-of-milos")), - ) - } - } - - @Test - fun `extract related anime if there are only same franchise entries`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_only.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-entertainment-district-arc")), - RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-movie-mugen-train"), "Sequel"), - RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-mugen-train"), "Sequel"), - RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-swordsmith-village-arc")), - RelatedAnime(URI("https://anime-planet.com/anime/kimetsu-gakuen-monogatari-kimetsu-no-utage-tokubetsu-hen")), - RelatedAnime(URI("https://anime-planet.com/anime/kimetsu-gakuen-valentine-hen")), - ) - } - } - - @Test - fun `no related anime`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/no_related_anime.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `successfully extract the tags`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/various_tags.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "based on a manga", - "child protagonists", - "dark fantasy", - "fantasy", - "horror", - "isolated society", - "mind games", - "mystery", - "noitamina", - "orphanage", - "orphans", - "outside world", - "psychological", - "sci fi", - "shounen", - "thriller", - "domestic abuse", - "self-harm", - "mature themes", - ) - } - } - - @Test - fun `tags not available`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/no_tags.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/score.html")) - - // then - assertThat(result.scores).hasSize(1) - assertThat((result.scores.first()).scaledValue()).isEqualTo(8.844) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/no-score.html")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/multiple_studios.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimePlanetConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/no_studios.html") - - val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = AnimePlanetAnimeConverter.instance - - // when - val result = AnimePlanetAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnimePlanetAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.animeplanet + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import java.time.Clock +import java.time.LocalDate +import java.time.ZoneOffset.UTC +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS + +internal class AnimePlanetAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/title/special_chars.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("PriPara Movie: Minna no Akogare♪ Let's Go☆Prix Paris") + } + } + + @Test + fun `extract title from jsonld if it was replaces by 'email protected'`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("Malice@Doll") + } + } + + @Test + fun `extract title from meta tag if it was replaces by 'email protected' and jsonld does not exist`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/title/title_replaced_by_email_protected_no_jsonld.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("The iDOLM@STER Million Live!") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `1 episode`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1_ep.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `1081 and more episodes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/1081+_eps.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(1081) + } + } + + @Test + fun `25 episodes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/25_eps.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(25) + } + } + + @Test + fun `no episodes is mapped to 1 episode`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/episodes/no_episodes.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isZero() + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `'web' is mapped to ONA`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/web.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `'TV Special' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv_special.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'TV' is mapped to TV`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/tv.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `'OVA' is mapped to OVA`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/ova.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `'other' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/other.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'music video' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/music_video.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'movie' is mapped to Movie`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/movie.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `'dvd special' is mapped to Special`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/dvd_special.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `value without braces is extracted correctly`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/type/no_braces.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `correctly extract picture and thumbnail`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1.jpg") + assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/natsumes-book-of-friends-season-6-specials-1-190x242.jpg") + } + } + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") + assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/images/anime/default/default-anime-spring.png") + } + } + + @Test + fun `ensure that the primary screenshot is extracted as picture`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/picture_and_thumbnail/ensure_primary_screenshot_as_picture.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1.webp") + assertThat(result.thumbnail.toString()).isEqualTo("https://cdn.anime-planet.com/anime/primary/ado-usseewa-1-285x399.webp") + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'tba' is mapped to UPCOMING`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/tba.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `current year is mapped to UNKNOWN, because it is not possible to determine whether the anime has finished or is still running`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_is_current_year.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `year in the future is mapped to UPCOMING`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_future.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `year in the past is mapped to FINISHED`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/year_in_the_past.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `range starting in the past is mapped to ONGOING`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_in_the_past.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `range starting in the same year is mapped to UNKNOWN, because it is not possible to determine whether the anime has already started or not`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_to_unknown_-_starts_same_year.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2023, 4, 28).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `range ending in the past`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_in_the_past.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `range ending same year`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/status/range_-_ends_same_year.html") + + val converter = AnimePlanetAnimeConverter( + metaDataProviderConfig = testAnimePlanetConfig, + clock = Clock.fixed(LocalDate.of(2021, 7, 9).atStartOfDay().toInstant(UTC), UTC) + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hour`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_hour.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 min`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/1_min.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `2 hours`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `2 hours 15 minutes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/2_hours_15_min.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) + } + } + + @Test + fun `10 minutes`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/duration/10_min.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Test + fun `season is undefined, but year is set`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/year_only.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 2016)) + } + } + + @Test + fun `season is summer`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/summer.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(SUMMER, 2020)) + } + } + + @Test + fun `season is spring`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/spring.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(SPRING, 2020)) + } + } + + @Test + fun `season is winter`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/winter.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(WINTER, 2020)) + } + } + + @Test + fun `season is fall`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/fall.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(FALL, 2020)) + } + } + + @Test + fun `tba - no year, no season`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/anime_season/tba.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason).isEqualTo(AnimeSeason(UNDEFINED, 0)) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract url slug as id`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/sources/id.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.sources).containsExactly(URI("https://anime-planet.com/anime/code-geass-lelouch-of-the-rebellion-r2")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `successfully extract the alternative title`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_available.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactly("Kimi no Na wa.") + } + } + + @Test + fun `alternative title is not available`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_not_available.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `extract synonym from jsonld if it was replaces by 'email protected'`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/synonyms_replaced_by_email_protected.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactly("GJ Club@") + } + } + + @Test + fun `extract multiple synonyms`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/synonyms/multiple_synonyms.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "\"Space Battleship Yamato\" Era: The Choice in 2202", + "\"Uchuu Senkan Yamato\" to Iu Jidai: Seireki 2202-nen no Sentaku", + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `extract same franchise and other franchise`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = + loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_and_other_franchise.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-4-koma-theater"), "Omake"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-brotherhood-specials"), "Side Story"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-premium-collection"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-reflections"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-the-movie-conqueror-of-shamballa"), "Other Franchise"), + RelatedAnime(URI("https://anime-planet.com/anime/fullmetal-alchemist-the-sacred-star-of-milos")), + ) + } + } + + @Test + fun `extract related anime if there are only same franchise entries`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/same_franchise_only.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-entertainment-district-arc")), + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-movie-mugen-train"), "Sequel"), + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-mugen-train"), "Sequel"), + RelatedAnime(URI("https://anime-planet.com/anime/demon-slayer-kimetsu-no-yaiba-swordsmith-village-arc")), + RelatedAnime(URI("https://anime-planet.com/anime/kimetsu-gakuen-monogatari-kimetsu-no-utage-tokubetsu-hen")), + RelatedAnime(URI("https://anime-planet.com/anime/kimetsu-gakuen-valentine-hen")), + ) + } + } + + @Test + fun `no related anime`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/related_anime/no_related_anime.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `successfully extract the tags`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/various_tags.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "based on a manga", + "child protagonists", + "dark fantasy", + "fantasy", + "horror", + "isolated society", + "mind games", + "mystery", + "noitamina", + "orphanage", + "orphans", + "outside world", + "psychological", + "sci fi", + "shounen", + "thriller", + "domestic abuse", + "self-harm", + "mature themes", + ) + } + } + + @Test + fun `tags not available`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/tags/no_tags.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/score.html")) + + // then + assertThat(result.scores).hasSize(1) + assertThat((result.scores.first()).scaledValue()).isEqualTo(8.844) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(loadTestResource("AnimePlanetAnimeConverterTest/scores/no-score.html")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/multiple_studios.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testAnimePlanetConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimePlanetConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimePlanetConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimePlanetAnimeConverterTest/studios/no_studios.html") + + val converter = AnimePlanetAnimeConverter(testAnimePlanetConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = AnimePlanetAnimeConverter.instance + + // when + val result = AnimePlanetAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnimePlanetAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt b/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt index 9dbc55cd..ce388863 100644 --- a/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt +++ b/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt @@ -1,463 +1,463 @@ -package io.github.manamiproject - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.normalize -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import kotlinx.coroutines.withContext -import java.net.URI -import java.time.Clock -import java.time.LocalDate -import kotlin.time.DurationUnit -import kotlin.time.DurationUnit.SECONDS -import kotlin.time.toDuration -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML from the mobile site version. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extracts specific data from raw content. - */ -public class AnimenewsnetworkAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnimenewsnetworkConfig, - private val extractor: DataExtractor = XmlDataExtractor, - private val clock: Clock = Clock.systemUTC(), -): AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract(rawContent, mapOf( - "source" to "//link[@rel='canonical']/@href", - "title" to "//h1[@id='page_header']/text()", - "numberOfEpisodes" to "//strong[text()='Number of episodes:']/following-sibling::span/text()", - "episodeTitles" to "//div[@id='infotype-25']//a/text()", - "score" to "//b[text()='Arithmetic mean:']/..", - "picture" to "//div[@id='infotype-19']//img/@src", - "year" to "//div[@id='infotype-7']//span/text()", - "year_list_first" to "//div[@id='infotype-7']//div[1]", - "synonyms" to "//div[@id='infotype-2']//div", - "genres" to "//div[@id='infotype-30']//a/text()", - "themes" to "//div[@id='infotype-31']//a/text()", - "relatedAnime" to "//div[@id='infotype-related']/html()", - "duration" to "//div[@id='infotype-4']//span/text()", - "year_list_all" to "//div[@id='infotype-7']//div//text()", - )) - - val japaneseCompanies = rawContent.substringAfter("<nobr>Japanese companies</nobr>") - .substringBefore("<nobr>") - - val productionData = extractor.extract(japaneseCompanies, mapOf( - "studios" to "//b[text()='Animation Production']/following-sibling::a/text()", - "producers" to "//b[text()='Production']/following-sibling::a/text()", - )) - - val extractedTitle = extractTitle(data) - - return@withContext AnimeRaw( - _title = extractedTitle.first, - _sources = extractSourcesEntry(data), - episodes = extractEpisodes(data), - type = extractType(data), - status = extractStatus(data), - picture = extractPicture(data), - thumbnail = extractThumbnail(data), - animeSeason = extractAnimeSeason(data), - duration = extractDuration(data), - _synonyms = extractSynonyms(data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(productionData), - _producers = extractProducers(productionData), - ).addScores(extractScore(data)) - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - return hashSetOf(URI(data.string("source").remove("www."))) - } - - private fun extractTitle(data: ExtractionResult): Pair<Title, Title> { - val rawTitle = data.string("title").normalize() - - return when { - rawTitle.endsWith(")") -> rawTitle.substringBeforeLast('(') to rawTitle - else -> rawTitle.substringBeforeLast('(') to EMPTY - } - } - - private fun extractEpisodes(data: ExtractionResult): Episodes { - var episodes = data.intOrDefault("numberOfEpisodes") - - if (episodes == 0 && !data.notFound("episodeTitles")) { - episodes = data.string("episodeTitles").remove("We have ").toIntOrNull() ?: 0 - } - - if (episodes == 0 && data.stringOrDefault("title").lowercase().contains("movie")) { - episodes = 1 - } - - return episodes - } - - private fun extractType(data: ExtractionResult): AnimeType { - val title = data.stringOrDefault("title").lowercase() - val type = title.substringAfterLast('(') - - return when { - type.contains("tv") -> TV - type.contains("movie") -> MOVIE - type.contains("oav") -> OVA - type.contains("ona") -> ONA - type.contains("special") -> SPECIAL - else -> when { - title.contains("motion picture") -> MOVIE - title.contains("ova") -> OVA - else -> UNKNOWN_TYPE - } - } - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.listNotNull<String>("score") - .first { it.contains("std. dev.") } - .substringBefore('(') - .normalize() - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore.toDoubleOrNull() ?: 0.0, - range = 1.0..10.0, - ) - } - - private fun extractThumbnail(data: ExtractionResult): URI { - if (data.notFound("picture")) { - return NO_PICTURE_THUMBNAIL - } - - val link = data.listNotNull<String>("picture").first() - val id = link.substringAfterLast("encyc/").normalize() - return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/fit200x200/encyc/$id") - } - - private fun extractPicture(data: ExtractionResult): URI { - if (data.notFound("picture")) { - return NO_PICTURE - } - - val link = data.listNotNull<String>("picture").first() - val id = link.substringAfterLast("encyc/").normalize() - return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/max500x600/encyc/$id") - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val yearExists = !data.notFound("year") - val yearListExists = !data.notFound("year_list_first") - - if (!yearExists && !yearListExists) { - return unknown_season - } - - // 1 Extraction from year - var matchResult = yearMonthRegex.matchEntire(data.string("year").substringBefore("to").normalize()) - - if ((matchResult == null || matchResult.groups.isEmpty()) && !yearListExists) { - return unknown_season - } - - var year = (matchResult?.groups?.get("year")?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR - var month = matchResult?.groups?.get("month")?.value?.toIntOrNull() ?: 0 - - // 2 Unable to extract year and no other options left - if (year == UNKNOWN_YEAR && !yearListExists) { - return unknown_season - } - - // 3 Extract from yearList - if (year == UNKNOWN_YEAR) { - matchResult = yearMonthRegex.matchEntire(data.string("year_list_first").substringBefore("to").normalize()) - - if (matchResult == null || matchResult.groups.isEmpty()) { - return unknown_season - } - - year = (matchResult.groups["year"]?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR - month = matchResult.groups["month"]?.value?.toIntOrNull() ?: 0 - } - - val season = when(month) { - 1, 2, 3 -> WINTER - 4, 5, 6 -> SPRING - 7, 8, 9 -> SUMMER - 10, 11, 12 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - year = year, - season = season, - ) - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - if (data.notFound("synonyms")) { - return hashSetOf() - } - - return data.listNotNull<Title>("synonyms") - .map { it.normalize() } - .map { title -> - val endsWithBracket = title.endsWith(')') - - if (endsWithBracket) { - var closingBrackets = 1 - var openingBracketSeen = 0 - var rangeEnd = title.length - 1 - - for (i in title.length - 2 downTo 0 step 1) { - when { - title[i] == ')' -> { - closingBrackets++ - rangeEnd = i - } - title[i] == '(' && openingBracketSeen != closingBrackets -> { - openingBracketSeen++ - rangeEnd = i - } - openingBracketSeen != closingBrackets -> rangeEnd = i - openingBracketSeen == closingBrackets && title[i] == ' ' -> rangeEnd = i - openingBracketSeen == closingBrackets && title[i] !in new_language_lock_identifiers -> break - } - } - - title.substring(0, rangeEnd) - } else { - title - } - } - .toHashSet() - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - val genres = if (data.notFound("genres")) { - hashSetOf() - } else { - data.listNotNull<Tag>("genres") - } - - val themes = if (data.notFound("themes")) { - hashSetOf() - } else { - data.listNotNull<Tag>("themes") - } - - return genres.union(themes).toHashSet() - } - - /** - * The relation is plain text next to the link: - * + `[ sequel of <a href="/encyclopedia/anime.php?id=7809">Clannad (TV)</a> ]` (header) → `sequel of` - * + `<a href="/encyclopedia/anime.php?id=33628">Frieren (TV 2)</a> (sequel)<br>` (list) → `sequel` - */ - private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { - if (data.notFound("relatedAnime")) { - return hashSetOf() - } - - return data.listNotNull<String>("relatedAnime").flatMap { html -> - relatedAnimeLinkRegex.findAll(html).map { match -> - val textBefore = html.substring(0, match.range.first).substringAfterLast('>') - val label = if (textBefore.contains('[')) { - textBefore.substringAfterLast('[') - } else { - relationInBracketsRegex.find(match.groups["after"]?.value ?: EMPTY)?.groups?.get("label")?.value - } - RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(match.groups["id"]!!.value), label) - }.toList() - }.toHashSet() - } - - private fun extractDuration(data: ExtractionResult): Duration { - if (data.notFound("duration")) { - return UNKNOWN_DURATION - } - - val value = data.string("duration").normalize().lowercase() - - if (durationMinutesRegex.matches(value)) { - val minutes = durationMinutesRegex.matchEntire(value) - ?.groups - ?.get("minutes") - ?.value - ?.toIntOrNull() - ?: 0 - - return Duration( - value = minutes, - unit = MINUTES, - ) - } - - if (durationHalfHourTextRegex.matches(value)) { - return Duration( - value = 30, - unit = MINUTES, - ) - } - - if (durationOneHourTextRegex.matches(value)) { - return Duration( - value = 1, - unit = HOURS, - ) - } - - if (durationMinSecondsRegex.matches(value)) { - val groups = durationMinSecondsRegex.matchEntire(value) - ?.groups - - val minutes = Duration( - value = groups - ?.get("minutes") - ?.value - ?.toIntOrNull() - ?: 0, - unit = MINUTES, - ) - - val seconds = Duration( - value = groups - ?.get("seconds") - ?.value - ?.toIntOrNull() - ?: 0, - unit = TimeUnit.SECONDS, - ) - - return Duration( - value = minutes.duration + seconds.duration, - unit = TimeUnit.SECONDS, - ) - } - - throw IllegalStateException("Unknown value [$value] for duration.") - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val yearExists = !data.notFound("year") - val yearListAllExists = !data.notFound("year_list_all") - - if (!yearExists && !yearListAllExists) { - return UNKNOWN_STATUS - } - - val year = data.string("year").normalize() - val yearListAll = data.listNotNull<String>("year_list_all").map { it.normalize() } - - val value = when { - yearMonthDayRegex.containsMatchIn(year) -> year - else -> { - yearListAll.filterNot { it.startsWith("Vintage:") }.firstOrNull { it.contains(" to ") } ?: yearListAll.firstOrNull() ?: EMPTY - } - } - - val splitDates = value.split(" to ").map { it.normalize() }.filter { str -> setOf(yearMonthRegex, yearMonthDayRegex).any { it.matches(str) } } - val now = LocalDate.now(clock) - - // Single date - if (splitDates.size == 1) { - val endYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: 0 - val endMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 - val endDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 - - if (endYear == 0) { - return UNKNOWN_STATUS - } - - val endDate = LocalDate.of(endYear, endMonth, endDay) - - return when { - now.isBefore(endDate) -> UPCOMING - now.isAfter(endDate) -> FINISHED - else -> ONGOING - } - } - - val startYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify startYear.") - val startMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 - val startDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 - val startDate = LocalDate.of(startYear, startMonth, startDay) - - val endYear = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify endYear.") - val endMonth = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 - val endDay = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 - val endDate = LocalDate.of(endYear, endMonth, endDay) - - return when { - now.isBefore(startDate) -> UPCOMING - now.isAfter(endDate) -> FINISHED - (now.isEqual(startDate) || now.isAfter(startDate)) && (now.isBefore(endDate) || now.isEqual(endDate)) -> ONGOING - else -> UNKNOWN_STATUS - } - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return if (data.notFound("producers")) { - hashSetOf() - } else { - data.listNotNull<Producer>("producers").toHashSet() - } - } - - public companion object { - /** - * Singleton of [AnimenewsnetworkAnimeConverter] - * @since 6.1.0 - */ - public val instance: AnimenewsnetworkAnimeConverter by lazy { AnimenewsnetworkAnimeConverter() } - - private val unknown_season = AnimeSeason( - year = UNKNOWN_YEAR, - season = UNDEFINED, - ) - - private val relatedAnimeLinkRegex = """<a [^>]*href="[^"]*anime\.php\?id=(?<id>\d+)"[^>]*>[^<]*</a>(?<after>[^<]*)""".toRegex() - private val relationInBracketsRegex = """^\s*\((?<label>[^)]*)\)""".toRegex() - private val yearMonthRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?.*?""".toRegex() - private val yearMonthDayRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?(-(?<day>\d{2}))?.*""".toRegex() - private val new_language_lock_identifiers = hashSetOf(' ', ')') - private val durationMinutesRegex = """^.*?(?<minutes>\d+) minutes ?.*?$""".toRegex() - private val durationHalfHourTextRegex = """^.*?half hour ?.*?$""".toRegex() - private val durationOneHourTextRegex = """^.*?one hour ?.*?$""".toRegex() - private val durationMinSecondsRegex = """^(?<minutes>\d+) +min\. +(?<seconds>\d+)s +minutes( per episode)?$""".toRegex() - } -} \ No newline at end of file +package io.github.manamiproject + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.normalize +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import kotlinx.coroutines.withContext +import java.net.URI +import java.time.Clock +import java.time.LocalDate +import kotlin.time.DurationUnit +import kotlin.time.DurationUnit.SECONDS +import kotlin.time.toDuration +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML from the mobile site version. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extracts specific data from raw content. + */ +public class AnimenewsnetworkAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnimenewsnetworkConfig, + private val extractor: DataExtractor = XmlDataExtractor, + private val clock: Clock = Clock.systemUTC(), +): AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract(rawContent, mapOf( + "source" to "//link[@rel='canonical']/@href", + "title" to "//h1[@id='page_header']/text()", + "numberOfEpisodes" to "//strong[text()='Number of episodes:']/following-sibling::span/text()", + "episodeTitles" to "//div[@id='infotype-25']//a/text()", + "score" to "//b[text()='Arithmetic mean:']/..", + "picture" to "//div[@id='infotype-19']//img/@src", + "year" to "//div[@id='infotype-7']//span/text()", + "year_list_first" to "//div[@id='infotype-7']//div[1]", + "synonyms" to "//div[@id='infotype-2']//div", + "genres" to "//div[@id='infotype-30']//a/text()", + "themes" to "//div[@id='infotype-31']//a/text()", + "relatedAnime" to "//div[@id='infotype-related']/html()", + "duration" to "//div[@id='infotype-4']//span/text()", + "year_list_all" to "//div[@id='infotype-7']//div//text()", + )) + + val japaneseCompanies = rawContent.substringAfter("<nobr>Japanese companies</nobr>") + .substringBefore("<nobr>") + + val productionData = extractor.extract(japaneseCompanies, mapOf( + "studios" to "//b[text()='Animation Production']/following-sibling::a/text()", + "producers" to "//b[text()='Production']/following-sibling::a/text()", + )) + + val extractedTitle = extractTitle(data) + + return@withContext AnimeRaw( + _title = extractedTitle.first, + _sources = extractSourcesEntry(data), + episodes = extractEpisodes(data), + type = extractType(data), + status = extractStatus(data), + picture = extractPicture(data), + thumbnail = extractThumbnail(data), + animeSeason = extractAnimeSeason(data), + duration = extractDuration(data), + _synonyms = extractSynonyms(data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(productionData), + _producers = extractProducers(productionData), + ).addScores(extractScore(data)) + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + return hashSetOf(URI(data.string("source").remove("www."))) + } + + private fun extractTitle(data: ExtractionResult): Pair<Title, Title> { + val rawTitle = data.string("title").normalize() + + return when { + rawTitle.endsWith(")") -> rawTitle.substringBeforeLast('(') to rawTitle + else -> rawTitle.substringBeforeLast('(') to EMPTY + } + } + + private fun extractEpisodes(data: ExtractionResult): Episodes { + var episodes = data.intOrDefault("numberOfEpisodes") + + if (episodes == 0 && !data.notFound("episodeTitles")) { + episodes = data.string("episodeTitles").remove("We have ").toIntOrNull() ?: 0 + } + + if (episodes == 0 && data.stringOrDefault("title").lowercase().contains("movie")) { + episodes = 1 + } + + return episodes + } + + private fun extractType(data: ExtractionResult): AnimeType { + val title = data.stringOrDefault("title").lowercase() + val type = title.substringAfterLast('(') + + return when { + type.contains("tv") -> TV + type.contains("movie") -> MOVIE + type.contains("oav") -> OVA + type.contains("ona") -> ONA + type.contains("special") -> SPECIAL + else -> when { + title.contains("motion picture") -> MOVIE + title.contains("ova") -> OVA + else -> UNKNOWN_TYPE + } + } + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.listNotNull<String>("score") + .first { it.contains("std. dev.") } + .substringBefore('(') + .normalize() + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore.toDoubleOrNull() ?: 0.0, + range = 1.0..10.0, + ) + } + + private fun extractThumbnail(data: ExtractionResult): URI { + if (data.notFound("picture")) { + return NO_PICTURE_THUMBNAIL + } + + val link = data.listNotNull<String>("picture").first() + val id = link.substringAfterLast("encyc/").normalize() + return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/fit200x200/encyc/$id") + } + + private fun extractPicture(data: ExtractionResult): URI { + if (data.notFound("picture")) { + return NO_PICTURE + } + + val link = data.listNotNull<String>("picture").first() + val id = link.substringAfterLast("encyc/").normalize() + return URI("https://cdn.${metaDataProviderConfig.hostname()}/thumbnails/max500x600/encyc/$id") + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val yearExists = !data.notFound("year") + val yearListExists = !data.notFound("year_list_first") + + if (!yearExists && !yearListExists) { + return unknown_season + } + + // 1 Extraction from year + var matchResult = yearMonthRegex.matchEntire(data.string("year").substringBefore("to").normalize()) + + if ((matchResult == null || matchResult.groups.isEmpty()) && !yearListExists) { + return unknown_season + } + + var year = (matchResult?.groups?.get("year")?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR + var month = matchResult?.groups?.get("month")?.value?.toIntOrNull() ?: 0 + + // 2 Unable to extract year and no other options left + if (year == UNKNOWN_YEAR && !yearListExists) { + return unknown_season + } + + // 3 Extract from yearList + if (year == UNKNOWN_YEAR) { + matchResult = yearMonthRegex.matchEntire(data.string("year_list_first").substringBefore("to").normalize()) + + if (matchResult == null || matchResult.groups.isEmpty()) { + return unknown_season + } + + year = (matchResult.groups["year"]?.value ?: EMPTY).toIntOrNull() ?: UNKNOWN_YEAR + month = matchResult.groups["month"]?.value?.toIntOrNull() ?: 0 + } + + val season = when(month) { + 1, 2, 3 -> WINTER + 4, 5, 6 -> SPRING + 7, 8, 9 -> SUMMER + 10, 11, 12 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + year = year, + season = season, + ) + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + if (data.notFound("synonyms")) { + return hashSetOf() + } + + return data.listNotNull<Title>("synonyms") + .map { it.normalize() } + .map { title -> + val endsWithBracket = title.endsWith(')') + + if (endsWithBracket) { + var closingBrackets = 1 + var openingBracketSeen = 0 + var rangeEnd = title.length - 1 + + for (i in title.length - 2 downTo 0 step 1) { + when { + title[i] == ')' -> { + closingBrackets++ + rangeEnd = i + } + title[i] == '(' && openingBracketSeen != closingBrackets -> { + openingBracketSeen++ + rangeEnd = i + } + openingBracketSeen != closingBrackets -> rangeEnd = i + openingBracketSeen == closingBrackets && title[i] == ' ' -> rangeEnd = i + openingBracketSeen == closingBrackets && title[i] !in new_language_lock_identifiers -> break + } + } + + title.substring(0, rangeEnd) + } else { + title + } + } + .toHashSet() + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + val genres = if (data.notFound("genres")) { + hashSetOf() + } else { + data.listNotNull<Tag>("genres") + } + + val themes = if (data.notFound("themes")) { + hashSetOf() + } else { + data.listNotNull<Tag>("themes") + } + + return genres.union(themes).toHashSet() + } + + /** + * The relation is plain text next to the link: + * + `[ sequel of <a href="/encyclopedia/anime.php?id=7809">Clannad (TV)</a> ]` (header) → `sequel of` + * + `<a href="/encyclopedia/anime.php?id=33628">Frieren (TV 2)</a> (sequel)<br>` (list) → `sequel` + */ + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("relatedAnime")) { + return hashSetOf() + } + + return data.listNotNull<String>("relatedAnime").flatMap { html -> + relatedAnimeLinkRegex.findAll(html).map { match -> + val textBefore = html.substring(0, match.range.first).substringAfterLast('>') + val label = if (textBefore.contains('[')) { + textBefore.substringAfterLast('[') + } else { + relationInBracketsRegex.find(match.groups["after"]?.value ?: EMPTY)?.groups?.get("label")?.value + } + RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(match.groups["id"]!!.value), label) + }.toList() + }.toHashSet() + } + + private fun extractDuration(data: ExtractionResult): Duration { + if (data.notFound("duration")) { + return UNKNOWN_DURATION + } + + val value = data.string("duration").normalize().lowercase() + + if (durationMinutesRegex.matches(value)) { + val minutes = durationMinutesRegex.matchEntire(value) + ?.groups + ?.get("minutes") + ?.value + ?.toIntOrNull() + ?: 0 + + return Duration( + value = minutes, + unit = MINUTES, + ) + } + + if (durationHalfHourTextRegex.matches(value)) { + return Duration( + value = 30, + unit = MINUTES, + ) + } + + if (durationOneHourTextRegex.matches(value)) { + return Duration( + value = 1, + unit = HOURS, + ) + } + + if (durationMinSecondsRegex.matches(value)) { + val groups = durationMinSecondsRegex.matchEntire(value) + ?.groups + + val minutes = Duration( + value = groups + ?.get("minutes") + ?.value + ?.toIntOrNull() + ?: 0, + unit = MINUTES, + ) + + val seconds = Duration( + value = groups + ?.get("seconds") + ?.value + ?.toIntOrNull() + ?: 0, + unit = TimeUnit.SECONDS, + ) + + return Duration( + value = minutes.duration + seconds.duration, + unit = TimeUnit.SECONDS, + ) + } + + throw IllegalStateException("Unknown value [$value] for duration.") + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val yearExists = !data.notFound("year") + val yearListAllExists = !data.notFound("year_list_all") + + if (!yearExists && !yearListAllExists) { + return UNKNOWN_STATUS + } + + val year = data.string("year").normalize() + val yearListAll = data.listNotNull<String>("year_list_all").map { it.normalize() } + + val value = when { + yearMonthDayRegex.containsMatchIn(year) -> year + else -> { + yearListAll.filterNot { it.startsWith("Vintage:") }.firstOrNull { it.contains(" to ") } ?: yearListAll.firstOrNull() ?: EMPTY + } + } + + val splitDates = value.split(" to ").map { it.normalize() }.filter { str -> setOf(yearMonthRegex, yearMonthDayRegex).any { it.matches(str) } } + val now = LocalDate.now(clock) + + // Single date + if (splitDates.size == 1) { + val endYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: 0 + val endMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 + val endDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 + + if (endYear == 0) { + return UNKNOWN_STATUS + } + + val endDate = LocalDate.of(endYear, endMonth, endDay) + + return when { + now.isBefore(endDate) -> UPCOMING + now.isAfter(endDate) -> FINISHED + else -> ONGOING + } + } + + val startYear = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify startYear.") + val startMonth = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 + val startDay = yearMonthDayRegex.matchEntire(splitDates.first())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 + val startDate = LocalDate.of(startYear, startMonth, startDay) + + val endYear = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("year")?.value?.toIntOrNull() ?: throw IllegalStateException("Unable to identify endYear.") + val endMonth = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("month")?.value?.toIntOrNull() ?: 1 + val endDay = yearMonthDayRegex.matchEntire(splitDates.last())?.groups?.get("day")?.value?.toIntOrNull() ?: 1 + val endDate = LocalDate.of(endYear, endMonth, endDay) + + return when { + now.isBefore(startDate) -> UPCOMING + now.isAfter(endDate) -> FINISHED + (now.isEqual(startDate) || now.isAfter(startDate)) && (now.isBefore(endDate) || now.isEqual(endDate)) -> ONGOING + else -> UNKNOWN_STATUS + } + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return if (data.notFound("producers")) { + hashSetOf() + } else { + data.listNotNull<Producer>("producers").toHashSet() + } + } + + public companion object { + /** + * Singleton of [AnimenewsnetworkAnimeConverter] + * @since 6.1.0 + */ + public val instance: AnimenewsnetworkAnimeConverter by lazy { AnimenewsnetworkAnimeConverter() } + + private val unknown_season = AnimeSeason( + year = UNKNOWN_YEAR, + season = UNDEFINED, + ) + + private val relatedAnimeLinkRegex = """<a [^>]*href="[^"]*anime\.php\?id=(?<id>\d+)"[^>]*>[^<]*</a>(?<after>[^<]*)""".toRegex() + private val relationInBracketsRegex = """^\s*\((?<label>[^)]*)\)""".toRegex() + private val yearMonthRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?.*?""".toRegex() + private val yearMonthDayRegex = """.*?(?<year>\d{4})(-(?<month>\d{2}))?(-(?<day>\d{2}))?.*""".toRegex() + private val new_language_lock_identifiers = hashSetOf(' ', ')') + private val durationMinutesRegex = """^.*?(?<minutes>\d+) minutes ?.*?$""".toRegex() + private val durationHalfHourTextRegex = """^.*?half hour ?.*?$""".toRegex() + private val durationOneHourTextRegex = """^.*?one hour ?.*?$""".toRegex() + private val durationMinSecondsRegex = """^(?<minutes>\d+) +min\. +(?<seconds>\d+)s +minutes( per episode)?$""".toRegex() + } +} \ No newline at end of file diff --git a/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt b/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt index 0b0082cb..705b43f6 100644 --- a/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt +++ b/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt @@ -1,1923 +1,1923 @@ -package io.github.manamiproject - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test - -internal class AnimenewsnetworkAnimeConverterTest { - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 6592`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/sources/6592.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=6592")) - } - } - } - - @Nested - inner class TitleTests { - - @Test - fun `title with special chars`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Pa-Pa-Pa the ★ Movie: Perman") - } - } - - @Test - fun `title with type is reduced to title only`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Kaminari Boy Pikkaribee") - } - } - - @Test - fun `title with type and number is reduced to title only`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type_and_number.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Pretty Guardian Sailor Moon Crystal") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `correctly extract 12 episodes from 'number of episodes'`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/12.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(12) - } - } - - @Test - fun `'number of episodes' is missing so take the value from 'episode titles' instead`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/number_of_episodes_missing_use_episodes_titles_instead.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(87) - } - } - - @Test - fun `no indicator for episodes, but 'movie' in title`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes_but_movie_in_title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `no indicator for episodes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isZero() - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `title contains TV in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/tv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `title contains MOVIE in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/movie.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `title contains OVA in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `title contains ONA in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ona.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `title contains SPECIAL in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `title contains motion picture`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/motion-picture-in-title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `title contains ova`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova-in-title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - } - - @Nested - inner class ScoreTests { - - @Test - fun `score exists`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isNotEmpty() - assertThat(result.scores.first().hostname).isEqualTo(AnimenewsnetworkConfig.hostname()) - assertThat(result.scores.first().value).isEqualTo(5.947) - } - } - - @Test - fun `no score`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/no-score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/fit200x200/encyc/A6074-2020173022.1442237283.jpg")) - assertThat(result.picture).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/max500x600/encyc/A6074-2020173022.1442237283.jpg")) - } - } - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `vintage - single date range`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-single-date-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2004) - } - } - - @Test - fun `vintage - year`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1992) - } - } - - @Test - fun `vintage - year-month-day`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `vintage - multiple entries`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-single-date.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2017) - } - } - - @Test - fun `vintage - multiple entries with a range`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1997) - } - } - - @Test - fun `vintage - missing`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-missing.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - - @Test - fun `vintage - year followed by text`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-followed-by-text.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2012) - } - } - } - - @Nested - inner class SeasonTests { - - @ParameterizedTest - @ValueSource(strings = ["01", "02", "03"]) - fun `mapped to WINTER`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["04", "05", "06"]) - fun `mapped to SPRING`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["07", "08", "09"]) - fun `mapped to SUMMER`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["10", "11", "12"]) - fun `mapped to FALL`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `multiple synonyms different languages`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Clannad: After Story", - "Кланнад. Продолжение истории", - "クラナド アフターストーリー", - "클라나드 애프터 스토리", - ) - } - } - - @Test - fun `no synonyms`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/no-synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `synonyms with nested brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/nestesd-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Guerreros Samurai", - "I cinque samurai", - "Les Samouraïs de l'Eternel", - "Los Cinco Samurais", - "Ronin Warriors", - "Samurai Troopers", - "Samurai Warrior", - "Samurai Warriors", - "Yoroiden Samurai Troopers", - "鎧伝サムライトルーパー", - "鎧傳", - "鎧甲聖鬥士", - "魔神坛斗士", - ) - } - } - - @Test - fun `synonyms with multiple meta info brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple-meta-info-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "As aventuras de Tom Sawyer", - "De avonturen van Tom Sawyer", - "Las Aventuras de Tom Sawyer", - "Le avventure di Tom Sawyer", - "Les Aventures de Tom Sawyer", - "Przygody Tomka Sawyera", - "Tom Sawyer", - "Tom Sawyer no Bōken", - "Tom Sawyers Abenteuer", - "Tom Story", - "توم سوير", - "ماجراهای تام سایر", - "トム・ソーヤーの冒険", - "湯姆歷險記", - ) - } - } - - @Test - fun `synonym with brackets in title without language info`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-without-language-info.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Evangelion: 3.0 (-46h) - You Can (Not) Redo", - ) - } - } - - @Test - fun `synonym with brackets in title with language info`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-with-language-info.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Evangelion 2.0: Break", - "Evangelion Shin Gekijōban: Ha", - "Evangelion: 2.0 You Can [Not] Advance", - "Evangelion: 2.22 Tu (No) Puedes Avanzar", - "Evangelion: 2.22 You Can (Not) Advance", - "Evangelion:2.22 Você (Não) Pode Avançar", - "Rebuild of Evangelion 2.0: Division", - "Rebuild of Evangelion: 2.0", - "Евангелион 2.22: ты (не) пройдешь", - "ヱヴァンゲリヲン新劇場版:破", - "福音戰士新劇場版:破", - "에반게리온 신극장판 파", - ) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `neither genres nor themes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/neither-genres-nor-themes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - - @Test - fun `multiple genres and multiple themes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/multiple-genres-and-multiple-themes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "comedy", - "drama", - "romance", - "supernatural", - "alternate future", - "amnesia", - "body switch", - "comets", - "coming-of-age", - "countryside", - "iyashikei", - "mythology", - "natural disaster", - "non-linear narrative", - "school", - "small town", - "time travel", - "tragedy", - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no related anime, but relation in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-but-relation-in-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=7809"), "sequel of") - ) - } - } - - @Test - fun `no related anime, no-relation in-brackets, but an adaption`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-no-relation-in-brackets-but-an-adaption.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `related anime`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=33628"), "sequel"), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=30277"), "spinoff"), - ) - } - } - - @Test - fun `related anime and relation in brackets`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime-and-relation-in-brackets.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=2960"), "alternate retelling of"), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=14079"), "side story"), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=12180"), "side story"), - ) - } - } - - @Test - fun `ignore manga in related anime`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/manga-in-related-anime.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=20000"), "spinoff of"), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=28763")), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=23886")), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=24366"), "sequel"), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=27622")), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26353"), "adaptation"), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26831"), "side story"), - RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=31113"), "sequel"), - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `80 minutes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/80-minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 80, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `24 minutes per episode`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/24-minutes-per-episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 24, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `half hour`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 30, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `half hour per episode`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour-per-episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 30, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `one hour`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 1, - unit = HOURS, - ) - ) - } - } - - @Test - fun `one hour per episode`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour-per-episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 1, - unit = HOURS, - ) - ) - } - } - - @Test - fun `1 min 30s minutes`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/1min-30s-minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 90, - unit = SECONDS, - ) - ) - } - } - - @Test - fun `numeric with error`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/numeric-with-error.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 111, - unit = MINUTES, - ) - ) - } - } - - @Test - fun `text with error`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/text-with-error.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo( - Duration( - value = 1, - unit = HOURS, - ) - ) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `vintage - no range, year only - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `vintage - no range, year only - ONGOING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1991-01-01T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - no range, year only - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1992-01-01T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - no range, year-month - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2019-11-30T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `vintage - no range, year-month - ONGOING only for first of month`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2019-12-01T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - no range, year-month - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2020-12-02T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - no range, year-month-day - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1993-05-27T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `vintage - no range, year-month-day - ONGOING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1993-05-28T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - no range, year-month-day - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("2019-05-29T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - no range, year-month - FINISHED special case where the suffix contains 'to', but not to set a range`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-special-case.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `vintage - range - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1991-09-20T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["1991-09-21", "1991-10-21"]) - fun `vintage - range - ONGOING`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `vintage - range - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1991-10-22T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `list - range - UPCOMING`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1972-09-30T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["1972-10-01", "1974-09-29"]) - fun `list - range - ONGOING`(input: String) { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `list - range - FINISHED`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() - } - - val testClock = Clock.fixed(Instant.parse("1974-09-30T16:02:42.00Z"), UTC) - - val converter = AnimenewsnetworkAnimeConverter( - testAnimenewsnetworkConfig, - clock = testClock, - ) - - val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/multiple_studios.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/no_studios.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/multiple_producers.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "ca-cygames anime fund", - "crunchyroll", - "jy animation", - "mixer", - "movic", - "north stars pictures", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) - } - - val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/no_producers.html") - - val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = AnimenewsnetworkAnimeConverter.instance - - // when - val result = AnimenewsnetworkAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(AnimenewsnetworkAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test + +internal class AnimenewsnetworkAnimeConverterTest { + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 6592`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/sources/6592.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=6592")) + } + } + } + + @Nested + inner class TitleTests { + + @Test + fun `title with special chars`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Pa-Pa-Pa the ★ Movie: Perman") + } + } + + @Test + fun `title with type is reduced to title only`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Kaminari Boy Pikkaribee") + } + } + + @Test + fun `title with type and number is reduced to title only`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/title/title_with_type_and_number.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Pretty Guardian Sailor Moon Crystal") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `correctly extract 12 episodes from 'number of episodes'`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/12.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(12) + } + } + + @Test + fun `'number of episodes' is missing so take the value from 'episode titles' instead`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/number_of_episodes_missing_use_episodes_titles_instead.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(87) + } + } + + @Test + fun `no indicator for episodes, but 'movie' in title`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes_but_movie_in_title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `no indicator for episodes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/episodes/no_episodes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isZero() + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `title contains TV in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/tv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `title contains MOVIE in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/movie.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `title contains OVA in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `title contains ONA in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ona.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `title contains SPECIAL in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `title contains motion picture`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/motion-picture-in-title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `title contains ova`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/type/ova-in-title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + } + + @Nested + inner class ScoreTests { + + @Test + fun `score exists`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isNotEmpty() + assertThat(result.scores.first().hostname).isEqualTo(AnimenewsnetworkConfig.hostname()) + assertThat(result.scores.first().value).isEqualTo(5.947) + } + } + + @Test + fun `no score`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/score/no-score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/fit200x200/encyc/A6074-2020173022.1442237283.jpg")) + assertThat(result.picture).isEqualTo(URI("https://cdn.animenewsnetwork.com/thumbnails/max500x600/encyc/A6074-2020173022.1442237283.jpg")) + } + } + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `vintage - single date range`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-single-date-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2004) + } + } + + @Test + fun `vintage - year`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1992) + } + } + + @Test + fun `vintage - year-month-day`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `vintage - multiple entries`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-single-date.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2017) + } + } + + @Test + fun `vintage - multiple entries with a range`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-multiple-entries-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1997) + } + } + + @Test + fun `vintage - missing`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-missing.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + + @Test + fun `vintage - year followed by text`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/year/vintage-year-followed-by-text.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2012) + } + } + } + + @Nested + inner class SeasonTests { + + @ParameterizedTest + @ValueSource(strings = ["01", "02", "03"]) + fun `mapped to WINTER`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["04", "05", "06"]) + fun `mapped to SPRING`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["07", "08", "09"]) + fun `mapped to SUMMER`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["10", "11", "12"]) + fun `mapped to FALL`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/anime_season/season/$input.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `multiple synonyms different languages`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Clannad: After Story", + "Кланнад. Продолжение истории", + "クラナド アフターストーリー", + "클라나드 애프터 스토리", + ) + } + } + + @Test + fun `no synonyms`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/no-synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `synonyms with nested brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/nestesd-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Guerreros Samurai", + "I cinque samurai", + "Les Samouraïs de l'Eternel", + "Los Cinco Samurais", + "Ronin Warriors", + "Samurai Troopers", + "Samurai Warrior", + "Samurai Warriors", + "Yoroiden Samurai Troopers", + "鎧伝サムライトルーパー", + "鎧傳", + "鎧甲聖鬥士", + "魔神坛斗士", + ) + } + } + + @Test + fun `synonyms with multiple meta info brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/multiple-meta-info-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "As aventuras de Tom Sawyer", + "De avonturen van Tom Sawyer", + "Las Aventuras de Tom Sawyer", + "Le avventure di Tom Sawyer", + "Les Aventures de Tom Sawyer", + "Przygody Tomka Sawyera", + "Tom Sawyer", + "Tom Sawyer no Bōken", + "Tom Sawyers Abenteuer", + "Tom Story", + "توم سوير", + "ماجراهای تام سایر", + "トム・ソーヤーの冒険", + "湯姆歷險記", + ) + } + } + + @Test + fun `synonym with brackets in title without language info`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-without-language-info.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Evangelion: 3.0 (-46h) - You Can (Not) Redo", + ) + } + } + + @Test + fun `synonym with brackets in title with language info`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/synonyms/synonym-with-brackets-in-title-with-language-info.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Evangelion 2.0: Break", + "Evangelion Shin Gekijōban: Ha", + "Evangelion: 2.0 You Can [Not] Advance", + "Evangelion: 2.22 Tu (No) Puedes Avanzar", + "Evangelion: 2.22 You Can (Not) Advance", + "Evangelion:2.22 Você (Não) Pode Avançar", + "Rebuild of Evangelion 2.0: Division", + "Rebuild of Evangelion: 2.0", + "Евангелион 2.22: ты (не) пройдешь", + "ヱヴァンゲリヲン新劇場版:破", + "福音戰士新劇場版:破", + "에반게리온 신극장판 파", + ) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `neither genres nor themes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/neither-genres-nor-themes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + + @Test + fun `multiple genres and multiple themes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/tags/multiple-genres-and-multiple-themes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "comedy", + "drama", + "romance", + "supernatural", + "alternate future", + "amnesia", + "body switch", + "comets", + "coming-of-age", + "countryside", + "iyashikei", + "mythology", + "natural disaster", + "non-linear narrative", + "school", + "small town", + "time travel", + "tragedy", + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no related anime, but relation in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-but-relation-in-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=7809"), "sequel of") + ) + } + } + + @Test + fun `no related anime, no-relation in-brackets, but an adaption`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/no-related-anime-no-relation-in-brackets-but-an-adaption.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `related anime`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=33628"), "sequel"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=30277"), "spinoff"), + ) + } + } + + @Test + fun `related anime and relation in brackets`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/related-anime-and-relation-in-brackets.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=2960"), "alternate retelling of"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=14079"), "side story"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=12180"), "side story"), + ) + } + } + + @Test + fun `ignore manga in related anime`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/related_anime/manga-in-related-anime.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=20000"), "spinoff of"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=28763")), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=23886")), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=24366"), "sequel"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=27622")), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26353"), "adaptation"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26831"), "side story"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=31113"), "sequel"), + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `80 minutes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/80-minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 80, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `24 minutes per episode`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/24-minutes-per-episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 24, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `half hour`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 30, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `half hour per episode`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/half-hour-per-episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 30, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `one hour`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 1, + unit = HOURS, + ) + ) + } + } + + @Test + fun `one hour per episode`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour-per-episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 1, + unit = HOURS, + ) + ) + } + } + + @Test + fun `1 min 30s minutes`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/1min-30s-minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 90, + unit = SECONDS, + ) + ) + } + } + + @Test + fun `numeric with error`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/numeric-with-error.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 111, + unit = MINUTES, + ) + ) + } + } + + @Test + fun `text with error`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/text-with-error.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo( + Duration( + value = 1, + unit = HOURS, + ) + ) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `vintage - no range, year only - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `vintage - no range, year only - ONGOING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1991-01-01T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - no range, year only - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1992-01-01T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - no range, year-month - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2019-11-30T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `vintage - no range, year-month - ONGOING only for first of month`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2019-12-01T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - no range, year-month - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2020-12-02T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - no range, year-month-day - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1993-05-27T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `vintage - no range, year-month-day - ONGOING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1993-05-28T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - no range, year-month-day - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("2019-05-29T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - no range, year-month - FINISHED special case where the suffix contains 'to', but not to set a range`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1990-12-31T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-year-month-special-case.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `vintage - range - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1991-09-20T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["1991-09-21", "1991-10-21"]) + fun `vintage - range - ONGOING`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `vintage - range - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1991-10-22T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/vintage-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `list - range - UPCOMING`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1972-09-30T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["1972-10-01", "1974-09-29"]) + fun `list - range - ONGOING`(input: String) { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("${input}T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `list - range - FINISHED`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val testClock = Clock.fixed(Instant.parse("1974-09-30T16:02:42.00Z"), UTC) + + val converter = AnimenewsnetworkAnimeConverter( + testAnimenewsnetworkConfig, + clock = testClock, + ) + + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/status/list-range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/multiple_studios.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/studios/no_studios.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/multiple_producers.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "ca-cygames anime fund", + "crunchyroll", + "jy animation", + "mixer", + "movic", + "north stars pictures", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + } + + val testFileContent = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/producers/no_producers.html") + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = AnimenewsnetworkAnimeConverter.instance + + // when + val result = AnimenewsnetworkAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(AnimenewsnetworkAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt b/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt index 0bfa235f..1aee9a66 100644 --- a/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt +++ b/anisearch/src/main/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverter.kt @@ -1,329 +1,329 @@ -package io.github.manamiproject.modb.anisearch - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.* -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.WRAP_TABLE_ROW -import io.github.manamiproject.modb.core.extractor.extractLabelledLinks -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import kotlinx.coroutines.withContext -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property xmlExtractor Extractor which retrieves the data from raw data. - * @property jsonExtractor Extractor which retrieves the data from raw data. - * @property relationsDir Directory containing the raw files for the related anime. - * @throws IllegalArgumentException if [relationsDir] doesn't exist or is not a directory. - */ -public class AnisearchAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = AnisearchConfig, - private val xmlExtractor: DataExtractor = XmlDataExtractor, - private val jsonExtractor: DataExtractor = JsonDataExtractor, - private val relationsDir: Directory, -) : AnimeConverter { - - init { - require(relationsDir.directoryExists()) { "Directory for relations [$relationsDir] does not exist or is not a directory." } - } - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = xmlExtractor.extract(rawContent, mapOf( - "jsonld" to "//script[@type='application/ld+json']/node()", - "image" to "//meta[@property='og:image']/@content", - "title" to "//meta[@property='og:title']/@content", - "type" to "//ul[@class='xlist row simple infoblock']//div[@class='type']", - "status" to "//div[@class='status']", - "duration" to "//ul[@class='xlist row simple infoblock']//time", - "tags" to "//section[@id='genres-tags']//ul[@class='cloud']//li//a/text()", - "source" to "//div[@id='content-outer']/@data-id", - "synonymsByLanguage" to "//div[@class='title']//strong/text()", - "synonymsBySubheader" to "//div[@class='title']//div[@class='grey']/text()", - "synonymsDivNoSpan" to "//div[@class='synonyms']", - "synonymsDivSpan" to "//div[@class='synonyms']//span[@id='text-synonyms']", - "synonymsItalic" to "//div[@class='synonyms']//i/text()", - "score" to "//td[text()='Calculated Value']/text()", - "studios" to "//div[@class='company']/span[text()='Studio:']/following-sibling::*/text()", - )) - - val jsonld = data.listNotNull<String>("jsonld").first() - val jsonData = jsonExtractor.extract(jsonld, mapOf( - "title" to "$.name", - "source" to "$.url", - "image" to "$.image", - "episodes" to "$.numberOfEpisodes", // they mess up the type. They use both strings and integer for episodes - "year" to "startDate", - "ratingValue" to "$.aggregateRating.ratingValue", - "worstRating" to "$.aggregateRating.worstRating", - "bestRating" to "$.aggregateRating.bestRating", - "ratingCount" to ".aggregateRating.ratingCount", - )) - - val picture = extractPicture(data) - - return@withContext AnimeRaw( - _title = extractTitle(jsonData, data), - episodes = extractEpisodes(jsonData), - type = extractType(data), - picture = picture, - thumbnail = generateThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(jsonData), - _sources = extractSourcesEntry(data), - _synonyms = extractSynonyms(data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = hashSetOf(), // not available on anisearch - ).addScores(extractScore(jsonData, data)) - } - - private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { - return jsonldData.stringOrDefault("title").ifBlank { data.stringOrDefault("title") } - .trim() - .remove(" (Anime)") - .trim() - } - - private fun extractEpisodes(jsonldData: ExtractionResult): Episodes = jsonldData.intOrDefault("episodes", 1) - - private fun extractType(data: ExtractionResult): AnimeType { - val type = data.string("type") - .split(',') - .first() - .trim() - .trimStart('[') - .lowercase() - - return when(type) { - "bonus" -> SPECIAL - "cm" -> SPECIAL - "movie" -> MOVIE - "music video" -> SPECIAL - "other" -> UNKNOWN_TYPE - "ova" -> OVA - "tv-series" -> TV - "tv-special" -> TV - "unknown" -> UNKNOWN_TYPE - "web" -> ONA - else -> throw IllegalStateException("Unmapped type [$type]") - } - } - - private fun extractPicture(data: ExtractionResult): URI { - // links in JSON are invalid (http 404) for a few weeks now. Have to solely rely on meta tag again - if (data.notFound("image")) { - return NO_PICTURE_THUMBNAIL - } - - val url = data.string("image") - - return when (url) { - DEFAULT_IMAGE -> NO_PICTURE - else -> URI(data.string("image").trim()) - } - } - - private fun generateThumbnail(picture: URI): URI { - if (picture == NO_PICTURE) { - return NO_PICTURE_THUMBNAIL - } - - val value = picture.toString() - .replace("_600.webp", "_300.webp") - return URI(value) - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val value = if (data.notFound("status")) { - EMPTY - } else { - data.listNotNull<String>("status").first() - } - - return when(value.trim().lowercase()) { - "aborted", "canceled" -> UNKNOWN_STATUS - "completed" -> FINISHED - "ongoing" -> ONGOING - "upcoming" -> UPCOMING - "on hold", EMPTY -> UNKNOWN_STATUS - else -> throw IllegalStateException("Unmapped status [$value]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val textValue = data.stringOrDefault("duration", "0") - .trim() - .lowercase() - - val value = """\d+""".toRegex().find(textValue)!!.value.toInt() - val fallbackUnit = "unknown" - val extractedUnit = """[aA-zZ]+""".toRegex().find(textValue)?.value ?: fallbackUnit - - when { - value > 0 && extractedUnit == fallbackUnit -> throw IllegalStateException("Value for duration is present [], but unit is unknown") - value == 0 && extractedUnit == fallbackUnit -> return Duration.UNKNOWN - } - - val unit = when(extractedUnit) { - "hrs" -> HOURS - "min" -> MINUTES - else -> throw IllegalStateException("Unmapped duration unit [$extractedUnit]") - } - - return Duration(value, unit) - } - - private fun extractAnimeSeason(jsonldData: ExtractionResult): AnimeSeason { - val date = jsonldData.stringOrDefault("year") - if (date.eitherNullOrBlank()) { - return AnimeSeason() - } - - val year = """\d{4}""".toRegex().find(date)!!.value.toInt() - val fallback = "0" - val month = ("""-\d{2}-""".toRegex().find(date)?.value?.replace("""-""".toRegex(), EMPTY)?.ifBlank { fallback } ?: fallback).toInt() - - val season = when(month) { - 1, 2, 3 -> WINTER - 4, 5, 6 -> SPRING - 7, 8, 9 -> SUMMER - 10, 11, 12 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - year = year, - season = season - ) - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("source"))) - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - val synonyms = if (data.notFound("synonymsByLanguage")) { - hashSetOf() - } else { - data.listNotNull<Title>("synonymsByLanguage").toHashSet() - } - - if (!data.notFound("synonymsBySubheader")) { - data.listNotNull<Title>("synonymsBySubheader").forEach { synonyms.add(it) } - } - - if (!data.notFound("synonymsDivNoSpan")) { - data.listNotNull<Title>("synonymsDivNoSpan") - .forEach { synonyms.add(it) } - } - - if (!data.notFound("synonymsDivSpan")) { - data.listNotNull<Title>("synonymsDivSpan") - .forEach { synonyms.add(it) } - } - - if (!data.notFound("synonymsItalic")) { - data.listNotNull<Title>("synonymsItalic") - .forEach { synonyms.add(it) } - } - - return synonyms.map { it.trimStart(',').trimEnd(',') }.toHashSet() - } - - private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> = withContext(LIMITED_CPU) { - val id = data.string("source") - val relationsFile = relationsDir.resolve("$id.${metaDataProviderConfig.fileSuffix()}") - - check(relationsFile.regularFileExists()) { "Relations file is missing for [$id]." } - - val relatedAnimeData = xmlExtractor.extract(relationsFile.readFile(), mapOf( - "relatedAnime" to "//section[@id='relations_anime']//table//tbody//tr/html()", - )) - - if (relatedAnimeData.notFound("relatedAnime")) { - hashSetOf() - } else { - // <tr><th><a href="anime/2753,ghost-in-the-shell-2-innocence">...</a><br><span ...>Sequel</span></th>...</tr> - xmlExtractor.extractLabelledLinks( - snippets = relatedAnimeData.listNotNull<String>("relatedAnime"), - labelSelector = "//th//span/text()", - linkSelector = "//a/@href", - wrap = WRAP_TABLE_ROW, - ).flatMap { entry -> entry.links.map { it to entry.label } } - .map { (link, label) -> link.remove("anime/") to label?.takeUnless { it.trim() == "?" } } // "?" = relation unknown - .map { (link, label) -> RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(link.substring(0, link.indexOf(','))), label) } - .toHashSet() - } - } - - private fun extractScore(jsonData: ExtractionResult, data: ExtractionResult): MetaDataProviderScore { - val from = jsonData.doubleOrDefault("worstRating", 0.1) - val to = jsonData.doubleOrDefault("bestRating", 5.0) - val rawScore = when { - !jsonData.notFound("ratingValue") -> { - // if there is effectively no rating they set the rating to 2.5 (more or less the middle of the range and set count to 1 - jsonData.doubleOrDefault("ratingValue") - .takeUnless { jsonData.int("ratingCount") == 1 && jsonData.doubleOrDefault("ratingValue") == 2.5 } - ?: 0.0 - } - !data.notFound("score") && jsonData.notFound("ratingValue") -> { - data.string("score") - .remove("Calculated Value") - .substringBefore('=') - .trim() - .toDoubleOrNull() ?: 0.0 - } - else -> 0.0 - } - - return if (rawScore == 0.0) { - NoMetaDataProviderScore - } else { - MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = from..to, - ) - } - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - return if (data.notFound("tags")) { - hashSetOf() - } else { - data.listNotNull<Tag>("tags").toHashSet() - } - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private companion object { - private const val DEFAULT_IMAGE = "https://cdn.anisearch.com/images/anime/cover/0_600.webp" - private const val DEFAULT_THUMBNAIL = "https://cdn.anisearch.com/images/anime/cover/0_300.webp" - } -} \ No newline at end of file +package io.github.manamiproject.modb.anisearch + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.HOURS +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.* +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.WRAP_TABLE_ROW +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import kotlinx.coroutines.withContext +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property xmlExtractor Extractor which retrieves the data from raw data. + * @property jsonExtractor Extractor which retrieves the data from raw data. + * @property relationsDir Directory containing the raw files for the related anime. + * @throws IllegalArgumentException if [relationsDir] doesn't exist or is not a directory. + */ +public class AnisearchAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = AnisearchConfig, + private val xmlExtractor: DataExtractor = XmlDataExtractor, + private val jsonExtractor: DataExtractor = JsonDataExtractor, + private val relationsDir: Directory, +) : AnimeConverter { + + init { + require(relationsDir.directoryExists()) { "Directory for relations [$relationsDir] does not exist or is not a directory." } + } + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = xmlExtractor.extract(rawContent, mapOf( + "jsonld" to "//script[@type='application/ld+json']/node()", + "image" to "//meta[@property='og:image']/@content", + "title" to "//meta[@property='og:title']/@content", + "type" to "//ul[@class='xlist row simple infoblock']//div[@class='type']", + "status" to "//div[@class='status']", + "duration" to "//ul[@class='xlist row simple infoblock']//time", + "tags" to "//section[@id='genres-tags']//ul[@class='cloud']//li//a/text()", + "source" to "//div[@id='content-outer']/@data-id", + "synonymsByLanguage" to "//div[@class='title']//strong/text()", + "synonymsBySubheader" to "//div[@class='title']//div[@class='grey']/text()", + "synonymsDivNoSpan" to "//div[@class='synonyms']", + "synonymsDivSpan" to "//div[@class='synonyms']//span[@id='text-synonyms']", + "synonymsItalic" to "//div[@class='synonyms']//i/text()", + "score" to "//td[text()='Calculated Value']/text()", + "studios" to "//div[@class='company']/span[text()='Studio:']/following-sibling::*/text()", + )) + + val jsonld = data.listNotNull<String>("jsonld").first() + val jsonData = jsonExtractor.extract(jsonld, mapOf( + "title" to "$.name", + "source" to "$.url", + "image" to "$.image", + "episodes" to "$.numberOfEpisodes", // they mess up the type. They use both strings and integer for episodes + "year" to "startDate", + "ratingValue" to "$.aggregateRating.ratingValue", + "worstRating" to "$.aggregateRating.worstRating", + "bestRating" to "$.aggregateRating.bestRating", + "ratingCount" to ".aggregateRating.ratingCount", + )) + + val picture = extractPicture(data) + + return@withContext AnimeRaw( + _title = extractTitle(jsonData, data), + episodes = extractEpisodes(jsonData), + type = extractType(data), + picture = picture, + thumbnail = generateThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(jsonData), + _sources = extractSourcesEntry(data), + _synonyms = extractSynonyms(data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = hashSetOf(), // not available on anisearch + ).addScores(extractScore(jsonData, data)) + } + + private fun extractTitle(jsonldData: ExtractionResult, data: ExtractionResult): Title { + return jsonldData.stringOrDefault("title").ifBlank { data.stringOrDefault("title") } + .trim() + .remove(" (Anime)") + .trim() + } + + private fun extractEpisodes(jsonldData: ExtractionResult): Episodes = jsonldData.intOrDefault("episodes", 1) + + private fun extractType(data: ExtractionResult): AnimeType { + val type = data.string("type") + .split(',') + .first() + .trim() + .trimStart('[') + .lowercase() + + return when(type) { + "bonus" -> SPECIAL + "cm" -> SPECIAL + "movie" -> MOVIE + "music video" -> SPECIAL + "other" -> UNKNOWN_TYPE + "ova" -> OVA + "tv-series" -> TV + "tv-special" -> TV + "unknown" -> UNKNOWN_TYPE + "web" -> ONA + else -> throw IllegalStateException("Unmapped type [$type]") + } + } + + private fun extractPicture(data: ExtractionResult): URI { + // links in JSON are invalid (http 404) for a few weeks now. Have to solely rely on meta tag again + if (data.notFound("image")) { + return NO_PICTURE_THUMBNAIL + } + + val url = data.string("image") + + return when (url) { + DEFAULT_IMAGE -> NO_PICTURE + else -> URI(data.string("image").trim()) + } + } + + private fun generateThumbnail(picture: URI): URI { + if (picture == NO_PICTURE) { + return NO_PICTURE_THUMBNAIL + } + + val value = picture.toString() + .replace("_600.webp", "_300.webp") + return URI(value) + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val value = if (data.notFound("status")) { + EMPTY + } else { + data.listNotNull<String>("status").first() + } + + return when(value.trim().lowercase()) { + "aborted", "canceled" -> UNKNOWN_STATUS + "completed" -> FINISHED + "ongoing" -> ONGOING + "upcoming" -> UPCOMING + "on hold", EMPTY -> UNKNOWN_STATUS + else -> throw IllegalStateException("Unmapped status [$value]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val textValue = data.stringOrDefault("duration", "0") + .trim() + .lowercase() + + val value = """\d+""".toRegex().find(textValue)!!.value.toInt() + val fallbackUnit = "unknown" + val extractedUnit = """[aA-zZ]+""".toRegex().find(textValue)?.value ?: fallbackUnit + + when { + value > 0 && extractedUnit == fallbackUnit -> throw IllegalStateException("Value for duration is present [], but unit is unknown") + value == 0 && extractedUnit == fallbackUnit -> return Duration.UNKNOWN + } + + val unit = when(extractedUnit) { + "hrs" -> HOURS + "min" -> MINUTES + else -> throw IllegalStateException("Unmapped duration unit [$extractedUnit]") + } + + return Duration(value, unit) + } + + private fun extractAnimeSeason(jsonldData: ExtractionResult): AnimeSeason { + val date = jsonldData.stringOrDefault("year") + if (date.eitherNullOrBlank()) { + return AnimeSeason() + } + + val year = """\d{4}""".toRegex().find(date)!!.value.toInt() + val fallback = "0" + val month = ("""-\d{2}-""".toRegex().find(date)?.value?.replace("""-""".toRegex(), EMPTY)?.ifBlank { fallback } ?: fallback).toInt() + + val season = when(month) { + 1, 2, 3 -> WINTER + 4, 5, 6 -> SPRING + 7, 8, 9 -> SUMMER + 10, 11, 12 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + year = year, + season = season + ) + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("source"))) + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + val synonyms = if (data.notFound("synonymsByLanguage")) { + hashSetOf() + } else { + data.listNotNull<Title>("synonymsByLanguage").toHashSet() + } + + if (!data.notFound("synonymsBySubheader")) { + data.listNotNull<Title>("synonymsBySubheader").forEach { synonyms.add(it) } + } + + if (!data.notFound("synonymsDivNoSpan")) { + data.listNotNull<Title>("synonymsDivNoSpan") + .forEach { synonyms.add(it) } + } + + if (!data.notFound("synonymsDivSpan")) { + data.listNotNull<Title>("synonymsDivSpan") + .forEach { synonyms.add(it) } + } + + if (!data.notFound("synonymsItalic")) { + data.listNotNull<Title>("synonymsItalic") + .forEach { synonyms.add(it) } + } + + return synonyms.map { it.trimStart(',').trimEnd(',') }.toHashSet() + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> = withContext(LIMITED_CPU) { + val id = data.string("source") + val relationsFile = relationsDir.resolve("$id.${metaDataProviderConfig.fileSuffix()}") + + check(relationsFile.regularFileExists()) { "Relations file is missing for [$id]." } + + val relatedAnimeData = xmlExtractor.extract(relationsFile.readFile(), mapOf( + "relatedAnime" to "//section[@id='relations_anime']//table//tbody//tr/html()", + )) + + if (relatedAnimeData.notFound("relatedAnime")) { + hashSetOf() + } else { + // <tr><th><a href="anime/2753,ghost-in-the-shell-2-innocence">...</a><br><span ...>Sequel</span></th>...</tr> + xmlExtractor.extractLabelledLinks( + snippets = relatedAnimeData.listNotNull<String>("relatedAnime"), + labelSelector = "//th//span/text()", + linkSelector = "//a/@href", + wrap = WRAP_TABLE_ROW, + ).flatMap { entry -> entry.links.map { it to entry.label } } + .map { (link, label) -> link.remove("anime/") to label?.takeUnless { it.trim() == "?" } } // "?" = relation unknown + .map { (link, label) -> RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(link.substring(0, link.indexOf(','))), label) } + .toHashSet() + } + } + + private fun extractScore(jsonData: ExtractionResult, data: ExtractionResult): MetaDataProviderScore { + val from = jsonData.doubleOrDefault("worstRating", 0.1) + val to = jsonData.doubleOrDefault("bestRating", 5.0) + val rawScore = when { + !jsonData.notFound("ratingValue") -> { + // if there is effectively no rating they set the rating to 2.5 (more or less the middle of the range and set count to 1 + jsonData.doubleOrDefault("ratingValue") + .takeUnless { jsonData.int("ratingCount") == 1 && jsonData.doubleOrDefault("ratingValue") == 2.5 } + ?: 0.0 + } + !data.notFound("score") && jsonData.notFound("ratingValue") -> { + data.string("score") + .remove("Calculated Value") + .substringBefore('=') + .trim() + .toDoubleOrNull() ?: 0.0 + } + else -> 0.0 + } + + return if (rawScore == 0.0) { + NoMetaDataProviderScore + } else { + MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = from..to, + ) + } + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + return if (data.notFound("tags")) { + hashSetOf() + } else { + data.listNotNull<Tag>("tags").toHashSet() + } + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private companion object { + private const val DEFAULT_IMAGE = "https://cdn.anisearch.com/images/anime/cover/0_600.webp" + private const val DEFAULT_THUMBNAIL = "https://cdn.anisearch.com/images/anime/cover/0_300.webp" + } +} \ No newline at end of file diff --git a/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt b/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt index 46af769c..47cf19ad 100644 --- a/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt +++ b/anisearch/src/test/kotlin/io/github/manamiproject/modb/anisearch/AnisearchAnimeConverterTest.kt @@ -1,2024 +1,2024 @@ -package io.github.manamiproject.modb.anisearch - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import io.github.manamiproject.modb.test.testResource -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import java.net.URI -import kotlin.io.path.copyTo -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - - -internal class AnisearchAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/special_chars.html") - "<html></html>".writeToFile(tempDir.resolve("15159.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("22/7: 8+3=?") - } - } - - @Test - fun `no suffix for title`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/title_not_set_in_jsonld.html") - "<html></html>".writeToFile(tempDir.resolve("4410.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo(".hack//G.U. Returner") - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(NO_PICTURE) - assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) - } - } - - @Test - fun `picture and thumbnail`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_600.webp")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_300.webp")) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 3633`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/sources/3633.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://anisearch.com/anime/3633")) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `'bonus' is mapped to SPECIAL`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/bonus.html") - "<html></html>".writeToFile(tempDir.resolve("10454.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'cm' is mapped to 'SPECIAL'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/cm.html") - "<html></html>".writeToFile(tempDir.resolve("12290.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'movie' is mapped to Movie`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/movie.html") - "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `'music video' is mapped to SPECIAL`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/music-video.html") - "<html></html>".writeToFile(tempDir.resolve("9830.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `other' is mapped to UNKNOWN`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/other.html") - "<html></html>".writeToFile(tempDir.resolve("16289.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `'ova' is mapped to OVA`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/ova.html") - "<html></html>".writeToFile(tempDir.resolve("3627.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `tv-series' is mapped to TV`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-series.html") - "<html></html>".writeToFile(tempDir.resolve("4946.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `tv-special' is mapped to TV`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-special.html") - "<html></html>".writeToFile(tempDir.resolve("13250.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `unknown' is mapped to UNKNOWN`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/unknown.html") - "<html></html>".writeToFile(tempDir.resolve("17467.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `web' is mapped to ONA`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/web.html") - "<html></html>".writeToFile(tempDir.resolve("14935.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple tags`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/multiple_tags.html") - "<html></html>".writeToFile(tempDir.resolve("15073.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "action comedy", - "adventure", - "alternative world", - "comedy", - "demon", - "ecchi", - "fantasy", - "guns", - "harem", - "knight", - "magic", - "parody", - "robots & android", - "science-fiction", - "swords & co", - ) - } - } - - @Test - fun `extract exactly one tag`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/one_tag.html") - "<html></html>".writeToFile(tempDir.resolve("613.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly("drama") - } - } - - @Test - fun `no tags available`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/no_tags.html") - "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes defaults to 1`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/unknown.html") - "<html></html>".writeToFile(tempDir.resolve("16578.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `1 episode`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1.html") - "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/10.html") - "<html></html>".writeToFile(tempDir.resolve("3135.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/100.html") - "<html></html>".writeToFile(tempDir.resolve("4138.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1818.html") - "<html></html>".writeToFile(tempDir.resolve("5801.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - - @Test - fun `although not a double in json the number of episodes is deserialized to kotlin type double`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/type-is-double.html") - "<html></html>".writeToFile(tempDir.resolve("16804.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(6) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hour`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_hour.html") - "<html></html>".writeToFile(tempDir.resolve("6247.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 minute`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_minute.html") - "<html></html>".writeToFile(tempDir.resolve("11051.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) - } - } - - @Test - fun `2 hours`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/2_hours.html") - "<html></html>".writeToFile(tempDir.resolve("6889.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `24 minutes per episode`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/24_minutes_per_episode.html") - "<html></html>".writeToFile(tempDir.resolve("14844.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) - } - } - - @Test - fun `63 minutes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/63_minutes_by_6_episodes.html") - "<html></html>".writeToFile(tempDir.resolve("7192.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(63, MINUTES)) - } - } - - @Test - fun `70 minutes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/70_minutes.html") - "<html></html>".writeToFile(tempDir.resolve("7163.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(70, MINUTES)) - } - } - - @Test - fun `134 minutes`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/134_minutes.html") - "<html></html>".writeToFile(tempDir.resolve("602.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(134, MINUTES)) - } - } - - @Test - fun `episodes and duration unknown`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_and_duration_unknown.html") - "<html></html>".writeToFile(tempDir.resolve("16711.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `episodes known duration unknown`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_known_duration_unknown.html") - "<html></html>".writeToFile(tempDir.resolve("17039.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `multiple synonyms`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/multiple_synonyms.html") - "<html></html>".writeToFile(tempDir.resolve("1958.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Bésame Licia", - "Embrasse-moi Lucile", - "Kiss Me Licia", - "Love Me Night", - "Love in Rock 'n Roll", - "Lucile, Amour et Rock'n'Roll", - "Rock'n' Roll Kids", - "愛してナイト", - ) - } - } - - @Test - fun `no synonyms`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/no_synonyms.html") - "<html></html>".writeToFile(tempDir.resolve("16260.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `single synonym`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/single_synonym.html") - "<html></html>".writeToFile(tempDir.resolve("14456.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly( - "センコロール 3", - ) - } - } - - @Test - fun `single synonym and romanji alteration`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/romanji_alteration.html") - "<html></html>".writeToFile(tempDir.resolve("13631.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Chikyūgai Shōnen Shōjo", - "Extra-Terrestrial Boys & Girls", - "Extraterrestial Boys and Girls", - "Jóvenes en órbita", - "Notre jeunesse en orbite", - "The Orbital Children", - "地球外少年少女", - ) - } - } - - @Test - fun `correctly separate hidden synonyms using 11197 as an example`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_11197.html") - "<html></html>".writeToFile(tempDir.resolve("11197.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "DanMachi OVA", - "Danmachi OAV", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", - "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", - "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", - ) - setOf( - "Danmachi OAV", - "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darou ka", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", - "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", - "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", - "DanMachi OVA", - "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", - "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", - ) - } - } - - @Test - fun `correctly separate hidden synonyms using 8724 as an example`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_8724.html") - "<html></html>".writeToFile(tempDir.resolve("8724.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "My Teen Romantic Comedy SNAFU OVA", - "My Youth Romantic Comedy Is Wrong as I Expected. OVA", - "Oregairu OVA", - "Yahari Ore no Seishun Love Come wa Machigatte Iru. OAD", - "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto o Negawazaru o Enai.", - "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", - "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", - "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ōkaran Koto o Negawazaru o Enai.", - "やはり俺の青春ラブコメはまちがっている。「こちらとしても彼ら彼女らの行く末に幸多からんことを願わざるを得ない。」", - ) - } - } - - @Test - fun `synonyms containing named parts are not split`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/synonyms_contain_named_parts.html") - "<html></html>".writeToFile(tempDir.resolve("15599.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Extra One Room: Second Season: Hanasaka Yui Experiments / Hanasaka Yui Gets Delivered / Nanahashi Minori Holds a Meeting / Nanahashi Minori Becomes a Big-Shot / Amatsuki Mashiro Becomes a Cat / Amatsuki Mashiro Gives Treatment", - "Extra One Room: Second Season: Hanasaka Yui wa Tameshite Miru / Hanasaka Yui wa Okurarete Kuru / Nanahashi Minori wa Kaigi Suru / Nanahashi Minori wa Oomono ni Naru / Amatsuki Mashiro wa Neko ni Naru / Amatsuki Mashiro wa Chiryou Suru", - "One Room 2nd Season Extra", - "One Room Second Season Special", - "One Room セカンドシーズン -extra-", - ) - } - } - - @Test - fun `hidden synonyms and named parts`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_and_named_parts.html") - "<html></html>".writeToFile(tempDir.resolve("8093.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Youko x Boku SS Special", - "Inu x Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", - "Inu x Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", - "Inu × Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", - "Inu x Boku SS Special", - "妖狐×僕SS 御狐神くん変化 / スイッチ / おままごと", - "Youko × Boku SS Special", - "Inu × Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", - "Inu x Boku SS: Miketsukami-kun Henka / Switch / Omamagoto", - "Inu × Boku SS Special", - "Inu × Boku Secret Service: Miketsukami’s Metamorphosis", - ) - } - } - - @Test - fun `correctly extract synonyms in italic`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/italic.html") - "<html></html>".writeToFile(tempDir.resolve("17015.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly( - "Bakugan: Battle Planet Season 4", - "Bakugan: Battle Planet Staffel 4", - "爆丸エボリューションズ", - ) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'aborted' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/aborted.html") - "<html></html>".writeToFile(tempDir.resolve("12433.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `'canceled' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/canceled.html") - "<html></html>".writeToFile(tempDir.resolve("13270.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `'on hold' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/on_hold.html") - "<html></html>".writeToFile(tempDir.resolve("16925.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `no status is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/no_status.html") - "<html></html>".writeToFile(tempDir.resolve("14494.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `'completed' is mapped to 'FINISHED'`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `completed is only based on the original release`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed_in_japan_upcoming_elsewhere.html") - "<html></html>".writeToFile(tempDir.resolve("6222.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `'ongoing' is mapped to ONGOING`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/ongoing.html") - "<html></html>".writeToFile(tempDir.resolve("1721.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'upcoming' is mapped to UPCOMING`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/upcoming.html") - "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @ParameterizedTest - @CsvSource(value = [ - "01, 14628", - "02, 14967", - "03, 16251", - ]) - fun `'jan', 'feb', and 'mar' are mapped to WINTER`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "04, 14508", - "05, 15264", - "06, 14935", - ]) - fun `'apr', 'may' and 'jul' are mapped to SPRING`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "07, 14816", - "08, 16401", - "09, 16401", - ]) - fun `'jul', 'aug' and 'sep' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "10, 15606", - "11, 15042", - "12, 14484", - ]) - fun `'oct', 'nov' and 'dec' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @CsvSource(value = [ - "year_only, 17467", - "unknown, 16275", - ]) - fun `if the month cannot be determined use UNDEFINED`(fileName: String, animeId: AnimeId) { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") - "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `2021-08-06`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/2021-08-06.html") - "<html></html>".writeToFile(tempDir.resolve("15890.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2021) - } - } - - @Test - fun `1958-11`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1958-11.html") - "<html></html>".writeToFile(tempDir.resolve("5976.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1958) - } - } - - @Test - fun `1991`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1991.html") - "<html></html>".writeToFile(tempDir.resolve("168.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1991) - } - } - - @Test - fun `unknown year`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/unknown.html") - "<html></html>".writeToFile(tempDir.resolve("16275.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no related anime but a single adaption`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption.html") - .copyTo(tempDir.resolve("14844.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no related anime`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime.html") - .copyTo(tempDir.resolve("10941.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `single related anime`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/single_related_anime_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/single_related_anime.html") - .copyTo(tempDir.resolve("16777.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(URI("https://anisearch.com/anime/17530")) - ) - } - } - - @Test - fun `multiple related anime`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/multiple_related_anime_main.html") - testResource("AnisearchAnimeConverterTest/related_anime/multiple_related_anime.html") - .copyTo(tempDir.resolve("4942.html")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(main) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://anisearch.com/anime/2088"), "Remake"), - RelatedAnime(URI("https://anisearch.com/anime/2753"), "Sequel"), - ) - } - } - - @Test - fun `throws exception if relations file is missing`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/related_anime_file_missing_main.html") - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = exceptionExpected<IllegalStateException> { - converter.convert(testFile) - } - - // then - assertThat(result).hasMessage("Relations file is missing for [4942].") - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/score.html") - "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).hasSize(1) - assertThat((result.scores.first()).scaledValue()).isEqualTo(9.081632653061224) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/no-score.html") - "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/multiple_studios.html") - "<html></html>".writeToFile(tempDir.resolve("1945.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "pierrot co., ltd.", - "studio deen co., ltd.", - ) - } - } - - @Test - fun `no studios`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = AnisearchConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/no_studios.html") - "<html></html>".writeToFile(tempDir.resolve("5419.${testAnisearchConfig.fileSuffix()}")) - - val converter = AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ConstructorTests { - - @Test - fun `throws exception if relationsDir doesn't exist`() { - tempDirectory { - // given - val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() - } - - // when - val result = exceptionExpected<IllegalArgumentException> { - AnisearchAnimeConverter( - metaDataProviderConfig = testAnisearchConfig, - relationsDir = tempDir.resolve("relations"), - ) - } - - // then - assertThat(result).hasMessage("Directory for relations [${tempDir.resolve("relations")}] does not exist or is not a directory.") - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.anisearch + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import io.github.manamiproject.modb.test.testResource +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import java.net.URI +import kotlin.io.path.copyTo +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + + +internal class AnisearchAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/special_chars.html") + "<html></html>".writeToFile(tempDir.resolve("15159.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("22/7: 8+3=?") + } + } + + @Test + fun `no suffix for title`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/title/title_not_set_in_jsonld.html") + "<html></html>".writeToFile(tempDir.resolve("4410.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo(".hack//G.U. Returner") + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(NO_PICTURE) + assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) + } + } + + @Test + fun `picture and thumbnail`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_600.webp")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.anisearch.com/images/anime/cover/3/3633_300.webp")) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 3633`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/sources/3633.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://anisearch.com/anime/3633")) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `'bonus' is mapped to SPECIAL`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/bonus.html") + "<html></html>".writeToFile(tempDir.resolve("10454.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'cm' is mapped to 'SPECIAL'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/cm.html") + "<html></html>".writeToFile(tempDir.resolve("12290.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'movie' is mapped to Movie`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/movie.html") + "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `'music video' is mapped to SPECIAL`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/music-video.html") + "<html></html>".writeToFile(tempDir.resolve("9830.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `other' is mapped to UNKNOWN`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/other.html") + "<html></html>".writeToFile(tempDir.resolve("16289.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `'ova' is mapped to OVA`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/ova.html") + "<html></html>".writeToFile(tempDir.resolve("3627.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `tv-series' is mapped to TV`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-series.html") + "<html></html>".writeToFile(tempDir.resolve("4946.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `tv-special' is mapped to TV`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/tv-special.html") + "<html></html>".writeToFile(tempDir.resolve("13250.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `unknown' is mapped to UNKNOWN`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/unknown.html") + "<html></html>".writeToFile(tempDir.resolve("17467.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `web' is mapped to ONA`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/type/web.html") + "<html></html>".writeToFile(tempDir.resolve("14935.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple tags`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/multiple_tags.html") + "<html></html>".writeToFile(tempDir.resolve("15073.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "action comedy", + "adventure", + "alternative world", + "comedy", + "demon", + "ecchi", + "fantasy", + "guns", + "harem", + "knight", + "magic", + "parody", + "robots & android", + "science-fiction", + "swords & co", + ) + } + } + + @Test + fun `extract exactly one tag`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/one_tag.html") + "<html></html>".writeToFile(tempDir.resolve("613.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly("drama") + } + } + + @Test + fun `no tags available`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/tags/no_tags.html") + "<html></html>".writeToFile(tempDir.resolve("20713.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes defaults to 1`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/unknown.html") + "<html></html>".writeToFile(tempDir.resolve("16578.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `1 episode`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1.html") + "<html></html>".writeToFile(tempDir.resolve("9981.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/10.html") + "<html></html>".writeToFile(tempDir.resolve("3135.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/100.html") + "<html></html>".writeToFile(tempDir.resolve("4138.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/1818.html") + "<html></html>".writeToFile(tempDir.resolve("5801.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + + @Test + fun `although not a double in json the number of episodes is deserialized to kotlin type double`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/episodes/type-is-double.html") + "<html></html>".writeToFile(tempDir.resolve("16804.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(6) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hour`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_hour.html") + "<html></html>".writeToFile(tempDir.resolve("6247.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 minute`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/1_minute.html") + "<html></html>".writeToFile(tempDir.resolve("11051.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, MINUTES)) + } + } + + @Test + fun `2 hours`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/2_hours.html") + "<html></html>".writeToFile(tempDir.resolve("6889.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `24 minutes per episode`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/24_minutes_per_episode.html") + "<html></html>".writeToFile(tempDir.resolve("14844.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) + } + } + + @Test + fun `63 minutes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/63_minutes_by_6_episodes.html") + "<html></html>".writeToFile(tempDir.resolve("7192.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(63, MINUTES)) + } + } + + @Test + fun `70 minutes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/70_minutes.html") + "<html></html>".writeToFile(tempDir.resolve("7163.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(70, MINUTES)) + } + } + + @Test + fun `134 minutes`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/134_minutes.html") + "<html></html>".writeToFile(tempDir.resolve("602.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(134, MINUTES)) + } + } + + @Test + fun `episodes and duration unknown`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_and_duration_unknown.html") + "<html></html>".writeToFile(tempDir.resolve("16711.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `episodes known duration unknown`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/duration/episodes_known_duration_unknown.html") + "<html></html>".writeToFile(tempDir.resolve("17039.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `multiple synonyms`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/multiple_synonyms.html") + "<html></html>".writeToFile(tempDir.resolve("1958.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Bésame Licia", + "Embrasse-moi Lucile", + "Kiss Me Licia", + "Love Me Night", + "Love in Rock 'n Roll", + "Lucile, Amour et Rock'n'Roll", + "Rock'n' Roll Kids", + "愛してナイト", + ) + } + } + + @Test + fun `no synonyms`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/no_synonyms.html") + "<html></html>".writeToFile(tempDir.resolve("16260.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `single synonym`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/single_synonym.html") + "<html></html>".writeToFile(tempDir.resolve("14456.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly( + "センコロール 3", + ) + } + } + + @Test + fun `single synonym and romanji alteration`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/romanji_alteration.html") + "<html></html>".writeToFile(tempDir.resolve("13631.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Chikyūgai Shōnen Shōjo", + "Extra-Terrestrial Boys & Girls", + "Extraterrestial Boys and Girls", + "Jóvenes en órbita", + "Notre jeunesse en orbite", + "The Orbital Children", + "地球外少年少女", + ) + } + } + + @Test + fun `correctly separate hidden synonyms using 11197 as an example`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_11197.html") + "<html></html>".writeToFile(tempDir.resolve("11197.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "DanMachi OVA", + "Danmachi OAV", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", + "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", + "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", + ) + setOf( + "Danmachi OAV", + "Danmachi: ¿qué Tiene De Malo Intentar Buscar Unos Baños Termales En Una Mazmorra?", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "Danmachi: Is It Wrong to Try to Pick Up Girls in a Dungeon? Ist es falsch, im Dungeon in heißen Quellen zu baden?", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darou ka", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darō ka: Familia Myth - Dungeon ni Onsen o Motomeru no wa Machigatte Iru Darō ka", + "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darouka: Familia Myth OVA", + "Dungeon ni Deai wo Motomeru no wa Machigatte Iru Darou ka: Familia Myth - Dungeon ni Onsen wo Motomeru no wa Machigatte Iru Darou ka", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Expect a Hot Spring in a Dungeon?", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Familia Myth OVA", + "DanMachi OVA", + "Is It Wrong to Try to Pick Up Girls in a Dungeon? Is It Wrong to Try to Soak in a Hot Spring in a Dungeon?", + "ダンジョンに出会いを求めるのは間違っているだろうか FAMILIA MYTH ダンジョンに温泉を求めるのは間違っているだろうか", + ) + } + } + + @Test + fun `correctly separate hidden synonyms using 8724 as an example`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_8724.html") + "<html></html>".writeToFile(tempDir.resolve("8724.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "My Teen Romantic Comedy SNAFU OVA", + "My Youth Romantic Comedy Is Wrong as I Expected. OVA", + "Oregairu OVA", + "Yahari Ore no Seishun Love Come wa Machigatte Iru. OAD", + "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto o Negawazaru o Enai.", + "Yahari Ore no Seishun Love Comedy wa Machigatteiru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", + "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ookaran Koto wo Negawazaru wo Enai.", + "Yahari Ore no Seishun Lovecome wa Machigatte Iru.: Kochira to Shite mo Karera Kanojora no Yukusue ni Sachi Ōkaran Koto o Negawazaru o Enai.", + "やはり俺の青春ラブコメはまちがっている。「こちらとしても彼ら彼女らの行く末に幸多からんことを願わざるを得ない。」", + ) + } + } + + @Test + fun `synonyms containing named parts are not split`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/synonyms_contain_named_parts.html") + "<html></html>".writeToFile(tempDir.resolve("15599.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Extra One Room: Second Season: Hanasaka Yui Experiments / Hanasaka Yui Gets Delivered / Nanahashi Minori Holds a Meeting / Nanahashi Minori Becomes a Big-Shot / Amatsuki Mashiro Becomes a Cat / Amatsuki Mashiro Gives Treatment", + "Extra One Room: Second Season: Hanasaka Yui wa Tameshite Miru / Hanasaka Yui wa Okurarete Kuru / Nanahashi Minori wa Kaigi Suru / Nanahashi Minori wa Oomono ni Naru / Amatsuki Mashiro wa Neko ni Naru / Amatsuki Mashiro wa Chiryou Suru", + "One Room 2nd Season Extra", + "One Room Second Season Special", + "One Room セカンドシーズン -extra-", + ) + } + } + + @Test + fun `hidden synonyms and named parts`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/hidden_synonyms_and_named_parts.html") + "<html></html>".writeToFile(tempDir.resolve("8093.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Youko x Boku SS Special", + "Inu x Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", + "Inu x Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", + "Inu × Boku Secret Service: Miketsukami’s Metamorphosis / Switch / Playing House", + "Inu x Boku SS Special", + "妖狐×僕SS 御狐神くん変化 / スイッチ / おままごと", + "Youko × Boku SS Special", + "Inu × Boku Secret Service: Miketsukami-kun’s Transformations / Switch / Playing House", + "Inu x Boku SS: Miketsukami-kun Henka / Switch / Omamagoto", + "Inu × Boku SS Special", + "Inu × Boku Secret Service: Miketsukami’s Metamorphosis", + ) + } + } + + @Test + fun `correctly extract synonyms in italic`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/synonyms/italic.html") + "<html></html>".writeToFile(tempDir.resolve("17015.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly( + "Bakugan: Battle Planet Season 4", + "Bakugan: Battle Planet Staffel 4", + "爆丸エボリューションズ", + ) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'aborted' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/aborted.html") + "<html></html>".writeToFile(tempDir.resolve("12433.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `'canceled' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/canceled.html") + "<html></html>".writeToFile(tempDir.resolve("13270.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `'on hold' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/on_hold.html") + "<html></html>".writeToFile(tempDir.resolve("16925.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `no status is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/no_status.html") + "<html></html>".writeToFile(tempDir.resolve("14494.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `'completed' is mapped to 'FINISHED'`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `completed is only based on the original release`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/completed_in_japan_upcoming_elsewhere.html") + "<html></html>".writeToFile(tempDir.resolve("6222.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `'ongoing' is mapped to ONGOING`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/ongoing.html") + "<html></html>".writeToFile(tempDir.resolve("1721.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'upcoming' is mapped to UPCOMING`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/status/upcoming.html") + "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @ParameterizedTest + @CsvSource(value = [ + "01, 14628", + "02, 14967", + "03, 16251", + ]) + fun `'jan', 'feb', and 'mar' are mapped to WINTER`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "04, 14508", + "05, 15264", + "06, 14935", + ]) + fun `'apr', 'may' and 'jul' are mapped to SPRING`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "07, 14816", + "08, 16401", + "09, 16401", + ]) + fun `'jul', 'aug' and 'sep' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "10, 15606", + "11, 15042", + "12, 14484", + ]) + fun `'oct', 'nov' and 'dec' are mapped to SUMMER`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @CsvSource(value = [ + "year_only, 17467", + "unknown, 16275", + ]) + fun `if the month cannot be determined use UNDEFINED`(fileName: String, animeId: AnimeId) { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/season/$fileName.html") + "<html></html>".writeToFile(tempDir.resolve("$animeId.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `2021-08-06`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/2021-08-06.html") + "<html></html>".writeToFile(tempDir.resolve("15890.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2021) + } + } + + @Test + fun `1958-11`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1958-11.html") + "<html></html>".writeToFile(tempDir.resolve("5976.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1958) + } + } + + @Test + fun `1991`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/1991.html") + "<html></html>".writeToFile(tempDir.resolve("168.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1991) + } + } + + @Test + fun `unknown year`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/anime_season/year_of_premiere/unknown.html") + "<html></html>".writeToFile(tempDir.resolve("16275.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no related anime but a single adaption`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime_but_adaption.html") + .copyTo(tempDir.resolve("14844.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no related anime`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/no_related_anime_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/no_related_anime.html") + .copyTo(tempDir.resolve("10941.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `single related anime`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/single_related_anime_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/single_related_anime.html") + .copyTo(tempDir.resolve("16777.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://anisearch.com/anime/17530")) + ) + } + } + + @Test + fun `multiple related anime`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val main = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/multiple_related_anime_main.html") + testResource("AnisearchAnimeConverterTest/related_anime/multiple_related_anime.html") + .copyTo(tempDir.resolve("4942.html")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(main) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anisearch.com/anime/2088"), "Remake"), + RelatedAnime(URI("https://anisearch.com/anime/2753"), "Sequel"), + ) + } + } + + @Test + fun `throws exception if relations file is missing`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/related_anime/related_anime_file_missing_main.html") + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = exceptionExpected<IllegalStateException> { + converter.convert(testFile) + } + + // then + assertThat(result).hasMessage("Relations file is missing for [4942].") + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/score.html") + "<html></html>".writeToFile(tempDir.resolve("3633.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).hasSize(1) + assertThat((result.scores.first()).scaledValue()).isEqualTo(9.081632653061224) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/scores/no-score.html") + "<html></html>".writeToFile(tempDir.resolve("12224.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/multiple_studios.html") + "<html></html>".writeToFile(tempDir.resolve("1945.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "pierrot co., ltd.", + "studio deen co., ltd.", + ) + } + } + + @Test + fun `no studios`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnisearchConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("AnisearchAnimeConverterTest/studios/no_studios.html") + "<html></html>".writeToFile(tempDir.resolve("5419.${testAnisearchConfig.fileSuffix()}")) + + val converter = AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ConstructorTests { + + @Test + fun `throws exception if relationsDir doesn't exist`() { + tempDirectory { + // given + val testAnisearchConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun buildAnimeLink(id: AnimeId): URI = AnisearchConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnisearchConfig.fileSuffix() + } + + // when + val result = exceptionExpected<IllegalArgumentException> { + AnisearchAnimeConverter( + metaDataProviderConfig = testAnisearchConfig, + relationsDir = tempDir.resolve("relations"), + ) + } + + // then + assertThat(result).hasMessage("Directory for relations [${tempDir.resolve("relations")}] does not exist or is not a directory.") + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt index c48a4bed..562d369c 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/anime/AnimeRaw.kt @@ -1,703 +1,703 @@ -package io.github.manamiproject.modb.core.anime - -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import io.github.manamiproject.modb.core.extensions.normalize -import java.net.URI -import java.util.concurrent.ConcurrentHashMap -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - - -/** - * This class represents an anime directly converted from raw data of a metadata provider. - * It is also the type used to merge multiple anime of different metadata providers. - * @since 3.1.0 - * @param _title Main title. Must not be blank. - * @property title Main title. - * @param _sources Duplicate-free list of sources from which this anime was created. - * @property sources Duplicate-free list of sources from which this anime was created. - * @property type Distribution type. **Default** is [AnimeType.UNKNOWN]. - * @property episodes Number of episodes. **Default** is `0`. - * @property status Publishing status. **Default** is [AnimeStatus.UNKNOWN]. - * @property animeSeason In which season did the anime premiere. - * @property picture [URI] to a (large) poster/cover. **Default** is a self created "not found" pic. - * @property thumbnail [URI] to a thumbnail poster/cover. **Default** is a self created "not found" pic. - * @property duration Duration of an anime having one episode or average duration of an episode if the anime has more than one episode. - * @property scores List of scores provided by metadata providers. - * @param _synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. - * @property synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. - * @param _studios List of studio names as provided by the metadata providers. - * @property studios List of studio names. May contain duplicates for slightly different writings. All studio names are lower case. - * @param _producers List of names of producers as provided by the metadata providers. - * @property producers List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. - * @param _relatedAnime Duplicate-free list of links to related anime including the kind of relation. One entry per link. - * @property relatedAnime Duplicate-free list of links to related anime including the kind of relation. One entry per link. - * @param _tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags transformed to lower case. - * @property tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. - * @property metaDataProviderScores Contains a score for each metadata provider. The property will not be serialized as-is. It's serialized using backing field [scores]. - * @property activateChecks Disable any checks upon creating the object. This is only supposed to be used during safe deserialization. If created using `false` you can call [performChecks] manually. The property will not be serialized - * @throws IllegalArgumentException if _title is blank or number of episodes is negative. - */ -public data class AnimeRaw( - private var _title: Title, - private val _sources: HashSet<URI> = HashSet(), - val type: AnimeType = UNKNOWN_TYPE, - val episodes: Episodes = 0, - val status: AnimeStatus = UNKNOWN_STATUS, - val animeSeason: AnimeSeason = AnimeSeason(), - val picture: URI = NO_PICTURE, - val thumbnail: URI = NO_PICTURE_THUMBNAIL, - val duration: Duration = UNKNOWN_DURATION, - private val _synonyms: HashSet<Title> = HashSet(), - private val _studios: HashSet<Studio> = HashSet(), - private val _producers: HashSet<Producer> = HashSet(), - private val _relatedAnime: HashSet<RelatedAnime> = HashSet(), - private val _tags: HashSet<Tag> = HashSet(), - @Transient private val metaDataProviderScores: HashMap<Hostname, MetaDataProviderScoreValue> = hashMapOf(), - @Transient val activateChecks: Boolean = true, -) { - - /** - * Main title. - * @since 1.0.0 - */ - val title: Title - get() = _title - - /** - * Duplicate-free list of sources from which this anime was created. - * @since 16.6.0 - */ - val sources: HashSet<URI> - get() = _sources.toHashSet() - - /** - * Duplicate-free list of alternative titles. Synonyms are case-sensitive. - * @since 16.6.0 - */ - val synonyms: HashSet<Title> - get() = _synonyms.toHashSet() - - /** - * List of studio names. May contain duplicates for slightly different writings. All studios names are lower case. - * @since 18.4.0 - */ - val studios: HashSet<Studio> - get() = _studios.toHashSet() - - /** - * List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. - * @since 18.4.0 - */ - val producers: HashSet<Producer> - get() = _producers.toHashSet() - - /** - * Duplicate-free list of links to related anime including the kind of relation. One entry per link. - * @since 20.0.0 - */ - val relatedAnime: HashSet<RelatedAnime> - get() = _relatedAnime.toHashSet() - - /** - * Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. - * @since 16.6.0 - */ - val tags: HashSet<Tag> - get() = _tags.toHashSet() - - /** - * Duplicate-free list of scores. - * @since 17.0.0 - */ - val scores: HashSet<MetaDataProviderScoreValue> - get() = metaDataProviderScores.values.toHashSet() - - init { - require(title.neitherNullNorBlank()) { "Title cannot be blank." } - - if (activateChecks) { - performChecks() - } - } - - /** - * Add additional synonyms to the existing list. Duplicates are being ignored. - * Comparison for this is case-sensitive. This will **not** override [synonyms]. - * The value which is present in [title] cannot be added. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but - * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. - * @since 3.1.0 - * @param synonym Synonyms to be added. - * @return Same instance. - */ - public fun addSynonyms(vararg synonym: Title): AnimeRaw = addSynonyms(synonym.toHashSet()) - - /** - * Add additional synonyms to the existing list. Duplicates are being ignored. - * Comparison for this is case-sensitive. This will **not** override [synonyms]. - * The value which is present in [title] cannot be added. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but - * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. - * @since 1.0.0 - * @param synonyms List of synonyms. - * @return Same instance. - */ - public fun addSynonyms(synonyms: Collection<Title>): AnimeRaw { - synonyms.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .filter { it != _title } - .map { normalizeApostrophes(it, CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY) } - .forEach { _synonyms.add(it) } - - return this - } - - /** - * Removes a [Title] from [synonyms] if the given condition matches. - * @since 19.2.0 - * @param condition If an entry in [synonyms] matches this condition, then the [Title] will be removed from [synonyms]. - * @return Same instance. - */ - public fun removeSynonymIf(condition: (Title) -> Boolean): AnimeRaw { - _synonyms.removeIf { condition.invoke(it) } - return this - } - - /** - * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. - * To further prevent duplicates, the longest studio name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param studios Studio names to be added. - * @return Same instance. - */ - public fun addStudios(vararg studios: Studio): AnimeRaw = addStudios(studios.toHashSet()) - - /** - * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. - * To further prevent duplicates, the longest studio name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param studios List of studio names to be added. - * @return Same instance. - */ - public fun addStudios(studios: Collection<Studio>): AnimeRaw { - val newNormalized = studios.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .map { it.lowercase() } - .map { normalizeApostrophes(it) } - .toSet() - - val union =_studios.toHashSet().union(newNormalized).sortedBy { it.length } - - val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.startsWith(check) } - .any { lookup -> lookup.length > check.length } - }.filterNot { check -> // prevent duplicates with same suffix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.endsWith(check) } - .any { lookup -> lookup.length > check.length } - } - - _studios.clear() - removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _studios.add(it) } - - return this - } - - /** - * Add additional names of producers to the existing list. Producers are added in lower case. - * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param producers names of producers to be added. - * @return Same instance. - */ - public fun addProducers(vararg producers: Producer): AnimeRaw = addProducers(producers.toHashSet()) - - /** - * Add additional names of producers to the existing list. Producers are added in lower case. - * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: - * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified - * as the shorter version of `satelight inc.`. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 18.4.0 - * @param producers List of names of producers to be added. - * @return Same instance. - */ - public fun addProducers(producers: Collection<Producer>): AnimeRaw { - val newNormalized = producers.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .map { it.lowercase() } - .map { normalizeApostrophes(it) } - .toSet() - - val union =_producers.toHashSet().union(newNormalized).sortedBy { it.length } - - val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.startsWith(check) } - .any { lookup -> lookup.length > check.length } - }.filterNot { check -> // prevent duplicates with same suffix - union.filter { lookup -> lookup != check } - .filter { lookup -> lookup.endsWith(check) } - .any { lookup -> lookup.length > check.length } - } - - _producers.clear() - removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _producers.add(it) } - - return this - } - - /** - * Add additional sources to the existing list. This will **not** override [sources]. - * Duplicates are being ignored. - * @since 3.1.0 - * @param source Sources to be added. - * @return Same instance. - */ - public fun addSources(vararg source: URI): AnimeRaw = addSources(source.toHashSet()) - - /** - * Add additional sources to the existing list. This will **not** override [sources]. - * Duplicates are being ignored. - * @since 3.0.0 - * @param sources List of sources. - * @return Same instance. - */ - public fun addSources(sources: Collection<URI>): AnimeRaw { - _sources.addAll(sources) - - removeRelatedAnimeIf { sources.contains(it) } - - return this - } - - /** - * Removes an [URI] from [sources] if the given condition matches. - * @since 16.6.0 - * @param condition If an entry in [sources] matches this condition, then the [URI] will be removed from [sources]. - * @return Same instance. - */ - public fun removeSourceIf(condition: (URI) -> Boolean): AnimeRaw { - _sources.removeIf { condition.invoke(it) } - return this - } - - /** - * Add additional related anime to the existing list. This will **not** override [relatedAnime]. - * Links which are part of [sources] are ignored. There is only one entry per link: an entry having a - * [RelatedAnime.relation] replaces an existing entry without one, otherwise the existing entry is kept. - * @since 20.0.0 - * @param relatedAnime Related anime including the kind of relation. - * @return Same instance. - */ - public fun addRelatedAnime(vararg relatedAnime: RelatedAnime): AnimeRaw = addRelatedAnime(relatedAnime.toList()) - - /** - * Add additional related anime to the existing list. This will **not** override [relatedAnime]. - * Links which are part of [sources] are ignored. There is only one entry per link: an entry having a - * [RelatedAnime.relation] replaces an existing entry without one, otherwise the existing entry is kept. - * @since 20.0.0 - * @param relatedAnime List of related anime including the kind of relation. - * @return Same instance. - */ - public fun addRelatedAnime(relatedAnime: Collection<RelatedAnime>): AnimeRaw { - relatedAnime.asSequence() - .filter { !_sources.contains(it.uri) } - .forEach { new -> - val existing = _relatedAnime.firstOrNull { it.uri == new.uri } - when { - existing == null -> _relatedAnime.add(new) - existing.relation == null && new.relation != null -> { - _relatedAnime.remove(existing) - _relatedAnime.add(new) - } - } - } - - return this - } - - /** - * Add additional related anime for which the kind of relation is unknown. - * This will **not** override [relatedAnime]. See [addRelatedAnime]. - * @since 11.0.0 - * @param relatedAnime Links to related anime. - * @return Same instance. - */ - public fun addRelatedAnime(vararg relatedAnime: URI): AnimeRaw = addRelatedAnime(relatedAnime.map { RelatedAnime(it) }) - - /** - * Add additional related anime for which the kind of relation is unknown. - * This will **not** override [relatedAnime]. See [addRelatedAnime]. - * @since 11.0.0 - * @param relatedAnime Links to related anime. - * @return Same instance. - */ - @JvmName("addRelatedAnimeUris") - public fun addRelatedAnime(relatedAnime: Collection<URI>): AnimeRaw = addRelatedAnime(relatedAnime.map { RelatedAnime(it) }) - - /** - * Removes an entry from [relatedAnime] if the given condition matches its link. - * @since 11.0.0 - * @param condition If the link of an entry in [relatedAnime] matches this condition, then the entry will be removed from [relatedAnime]. - * @return Same instance. - */ - public fun removeRelatedAnimeIf(condition: (URI) -> Boolean): AnimeRaw { - _relatedAnime.removeIf { condition.invoke(it.uri) } - return this - } - - /** - * Add additional tags to the existing list. This will **not** override [tags]. - * Duplicates are being ignored. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 3.1.0 - * @param tags List of tags. - * @return Same instance. - */ - public fun addTags(vararg tags: Tag): AnimeRaw = addTags(tags.toHashSet()) - - /** - * Add additional tags to the existing list. This will **not** override [tags]. - * Duplicates are being ignored. - * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a - * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with - * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. - * @since 1.0.0 - * @param tags List of tags. - * @return Same instance. - */ - public fun addTags(tags: Collection<Tag>): AnimeRaw { - tags.asSequence() - .map { it.normalize() } - .filter { it.neitherNullNorBlank() } - .map { it.lowercase() } - .map { normalizeApostrophes(it) } - .forEach { _tags.add(it) } - - return this - } - - /** - * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. - * Only one instance of a score per metadata provider is supported. Latest score added wins. - * @since 17.0.0 - * @param scores List of scores provided by metadata providers. - * @return Same instance. - */ - public fun addScores(vararg scores: MetaDataProviderScore): AnimeRaw = addScores(scores.toHashSet()) - - /** - * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. - * Only one instance of a score per metadata provider is supported. Latest score added wins. - * @since 17.0.0 - * @param scores A score of a metadata provider. - * @return Same instance. - */ - public fun addScores(scores: Collection<MetaDataProviderScore>): AnimeRaw { - scores.filterIsInstance<MetaDataProviderScoreValue>().forEach { score -> - metaDataProviderScores[score.hostname] = score - } - - return this - } - - /** - * + Title and synonyms of the given [AnimeRaw] will both be added to the [synonyms] of this instance. - * + All sources of the given [AnimeRaw] will be added to the [sources] of this instance. - * + All related anime of the given [AnimeRaw] will be added to the [relatedAnime] of this instance. - * + All tags of the given [AnimeRaw] will be added to the [tags] of this instance. - * + All studios of the given [AnimeRaw] will be added to the [studios] of this instance. - * + All producers of the given [AnimeRaw] will be added to the [producers] of this instance. - * + In case the number of episodes of this instance is 0, the value of the given [AnimeRaw] will be applied. - * + In case the type of this instance is [AnimeType.UNKNOWN], the value of the given [AnimeRaw] will be applied. - * + In case the status of this instance is [AnimeStatus.UNKNOWN], the value of the given [AnimeRaw] will be applied. - * + In case the duration of this instance is [Duration.UNKNOWN], the value of the given [AnimeRaw] will be applied. - * + In case the season of this instance's [animeSeason] is [UNDEFINED], the season of the given [AnimeRaw] will be applied. - * + In case the year of this instance's [animeSeason] is [AnimeSeason.UNKNOWN_YEAR], the year if the given [AnimeRaw] will be applied. - * + All scores are collected. However, only one instance per metadata provider is kept. Latest score added wins. - * @since 1.0.0 - * @param anime [AnimeRaw] which is being merged into this instance. - * @return New instance of the merged anime. - */ - public fun mergeWith(anime: AnimeRaw): AnimeRaw { - val mergedEpisodes = if (episodes == 0 && anime.episodes != 0) { - anime.episodes - } else { - episodes - } - - val mergedType = if (type == UNKNOWN_TYPE && anime.type != UNKNOWN_TYPE) { - anime.type - } else { - type - } - - val mergedStatus = if (status == UNKNOWN_STATUS && anime.status != UNKNOWN_STATUS) { - anime.status - } else { - status - } - - val mergedDuration = if (duration == UNKNOWN_DURATION && anime.duration != UNKNOWN_DURATION) { - anime.duration - } else { - duration - } - - val mergedSeason = if (animeSeason.season == UNDEFINED && anime.animeSeason.season != UNDEFINED) { - anime.animeSeason.season - } else { - animeSeason.season - } - - val mergedYear = if (animeSeason.isYearOfPremiereUnknown() && anime.animeSeason.isYearOfPremiereKnown()) { - anime.animeSeason.year - } else { - animeSeason.year - } - - return AnimeRaw( - _title = title, - type = mergedType, - episodes = mergedEpisodes, - status = mergedStatus, - picture = picture, - thumbnail = thumbnail, - duration = mergedDuration, - animeSeason = AnimeSeason( - season = mergedSeason, - year = mergedYear, - ), - ).addSources(sources) - .addSources(anime.sources) - .addSynonyms(_synonyms) - .addSynonyms(anime.title) - .addSynonyms(anime.synonyms) - .addRelatedAnime(_relatedAnime) - .addRelatedAnime(anime.relatedAnime) - .addTags(_tags) - .addTags(anime.tags) - .addStudios(_studios) - .addStudios(anime.studios) - .addProducers(_producers) - .addProducers(anime.producers) - .addScores(metaDataProviderScores.values) - .addScores(anime.scores) - } - - /** - * Performs checks and fixes data. - * @since 11.0.0 - * @return Same instance of the anime. - * @throws IllegalArgumentException if _title is blank or number of episodes is negative. - */ - public fun performChecks(): AnimeRaw { - _title = _title.normalize() - require(_title.neitherNullNorBlank()) { "Title cannot be blank." } - - require(episodes >= 0) { "Episodes cannot have a negative value." } - - val uncheckedSources: Collection<URI> = _sources.toSet() - _sources.clear() - addSources(uncheckedSources) - - val uncheckedSynonyms: Collection<Title> = _synonyms.toSet() - _synonyms.clear() - addSynonyms(uncheckedSynonyms) - - val uncheckedStudios: Collection<Title> = _studios.toSet() - _studios.clear() - addStudios(uncheckedStudios) - - val uncheckedProducers: Collection<Title> = _producers.toSet() - _producers.clear() - addProducers(uncheckedProducers) - - val uncheckedRelatedAnime: Collection<RelatedAnime> = _relatedAnime.toSet() - _relatedAnime.clear() - addRelatedAnime(uncheckedRelatedAnime) - - val uncheckedTags: Collection<Tag> = _tags.toList() - _tags.clear() - addTags(uncheckedTags) - - return this - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AnimeRaw - - if (_title != other._title) return false - if (_sources != other.sources) return false - if (type != other.type) return false - if (episodes != other.episodes) return false - if (status != other.status) return false - if (animeSeason != other.animeSeason) return false - if (picture != other.picture) return false - if (thumbnail != other.thumbnail) return false - if (duration != other.duration) return false - if (scores != other.scores) return false - if (_synonyms != other.synonyms) return false - if (_studios != other.studios) return false - if (_producers != other.producers) return false - if (_relatedAnime != other.relatedAnime) return false - if (_tags != other.tags) return false - - return true - } - - override fun hashCode(): Int { - var result = _title.hashCode() - result = 31 * result + _sources.hashCode() - result = 31 * result + type.hashCode() - result = 31 * result + episodes - result = 31 * result + status.hashCode() - result = 31 * result + animeSeason.hashCode() - result = 31 * result + picture.hashCode() - result = 31 * result + thumbnail.hashCode() - result = 31 * result + duration.hashCode() - result = 31 * result + metaDataProviderScores.values.sorted().hashCode() - result = 31 * result + _synonyms.hashCode() - result = 31 * result + _studios.hashCode() - result = 31 * result + _producers.hashCode() - result = 31 * result + _relatedAnime.hashCode() - result = 31 * result + _tags.hashCode() - return result - } - - override fun toString(): String { - return """ - AnimeRaw( - sources = ${_sources.sorted()} - title = $_title - type = $type - episodes = $episodes - status = $status - animeSeason = $animeSeason - picture = $picture - thumbnail = $thumbnail - duration = $duration - scores = ${metaDataProviderScores.values.sortedBy { it.hostname }} - synonyms = ${_synonyms.sorted()} - studios = ${_studios.sorted()} - producers = ${_producers.sorted()} - relatedAnime = ${_relatedAnime.sorted()} - tags = ${_tags.sorted()} - ) - """.trimIndent() - } - - private fun removeOverlappingDuplicates(list: Collection<String>): Collection<String> { - if (list.isEmpty() || list.size == 1) { - return list - } - - val ret = ConcurrentHashMap.newKeySet<String>(list.size).apply { addAll(list) } - var lastSize: Int - - do { - lastSize = ret.size - - for (outerName in ret) { - val last = outerName.split(' ').lastOrNull() ?: continue - - for (innerName in ret) { - val first = innerName.split(' ').firstOrNull() ?: continue - - if (last == first && outerName != innerName && last !in falsePositives) { - when { - outerName.length > innerName.length -> ret.remove(innerName) - else -> ret.remove(outerName) - } - } - } - } - } while (lastSize > ret.size) - - return ret - } - - private fun normalizeApostrophes(value: String, normalizeTo: Char = SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY): String { - var normalized = value - - val apostropheAsSuffix = when { - normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX - else -> APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX - } - - if (apostropheAsSuffix.containsMatchIn(normalized)) { - apostropheAsSuffix.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> - normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) - } - } - - val apostropheUsage = when { - normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_USAGE_INCLUDING_U2019_REGEX - else -> APOSTROPH_USAGE_EXCEPT_U2019_REGEX - } - - if (apostropheUsage.containsMatchIn(normalized)) { - apostropheUsage.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> - normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) - } - } - - return normalized - } - - private companion object { - private const val CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY: Char = '\u2019' - private const val SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY: Char = '\u0027' - private val APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() - private val APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() - private val APOSTROPH_USAGE_EXCEPT_U2019_REGEX: Regex = """(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() - private val APOSTROPH_USAGE_INCLUDING_U2019_REGEX: Regex = """(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() - private val falsePositives = hashSetOf<String>( - "anime", - "studio", - "tv", - "tokyo", - "groove", - "media", - "production", - "animation", - "television", - "group", - ) - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.anime + +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.UNDEFINED +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import io.github.manamiproject.modb.core.extensions.normalize +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + + +/** + * This class represents an anime directly converted from raw data of a metadata provider. + * It is also the type used to merge multiple anime of different metadata providers. + * @since 3.1.0 + * @param _title Main title. Must not be blank. + * @property title Main title. + * @param _sources Duplicate-free list of sources from which this anime was created. + * @property sources Duplicate-free list of sources from which this anime was created. + * @property type Distribution type. **Default** is [AnimeType.UNKNOWN]. + * @property episodes Number of episodes. **Default** is `0`. + * @property status Publishing status. **Default** is [AnimeStatus.UNKNOWN]. + * @property animeSeason In which season did the anime premiere. + * @property picture [URI] to a (large) poster/cover. **Default** is a self created "not found" pic. + * @property thumbnail [URI] to a thumbnail poster/cover. **Default** is a self created "not found" pic. + * @property duration Duration of an anime having one episode or average duration of an episode if the anime has more than one episode. + * @property scores List of scores provided by metadata providers. + * @param _synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. + * @property synonyms Duplicate-free list of alternative titles. Synonyms are case-sensitive. + * @param _studios List of studio names as provided by the metadata providers. + * @property studios List of studio names. May contain duplicates for slightly different writings. All studio names are lower case. + * @param _producers List of names of producers as provided by the metadata providers. + * @property producers List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. + * @param _relatedAnime Duplicate-free list of links to related anime including the kind of relation. One entry per link. + * @property relatedAnime Duplicate-free list of links to related anime including the kind of relation. One entry per link. + * @param _tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags transformed to lower case. + * @property tags Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. + * @property metaDataProviderScores Contains a score for each metadata provider. The property will not be serialized as-is. It's serialized using backing field [scores]. + * @property activateChecks Disable any checks upon creating the object. This is only supposed to be used during safe deserialization. If created using `false` you can call [performChecks] manually. The property will not be serialized + * @throws IllegalArgumentException if _title is blank or number of episodes is negative. + */ +public data class AnimeRaw( + private var _title: Title, + private val _sources: HashSet<URI> = HashSet(), + val type: AnimeType = UNKNOWN_TYPE, + val episodes: Episodes = 0, + val status: AnimeStatus = UNKNOWN_STATUS, + val animeSeason: AnimeSeason = AnimeSeason(), + val picture: URI = NO_PICTURE, + val thumbnail: URI = NO_PICTURE_THUMBNAIL, + val duration: Duration = UNKNOWN_DURATION, + private val _synonyms: HashSet<Title> = HashSet(), + private val _studios: HashSet<Studio> = HashSet(), + private val _producers: HashSet<Producer> = HashSet(), + private val _relatedAnime: HashSet<RelatedAnime> = HashSet(), + private val _tags: HashSet<Tag> = HashSet(), + @Transient private val metaDataProviderScores: HashMap<Hostname, MetaDataProviderScoreValue> = hashMapOf(), + @Transient val activateChecks: Boolean = true, +) { + + /** + * Main title. + * @since 1.0.0 + */ + val title: Title + get() = _title + + /** + * Duplicate-free list of sources from which this anime was created. + * @since 16.6.0 + */ + val sources: HashSet<URI> + get() = _sources.toHashSet() + + /** + * Duplicate-free list of alternative titles. Synonyms are case-sensitive. + * @since 16.6.0 + */ + val synonyms: HashSet<Title> + get() = _synonyms.toHashSet() + + /** + * List of studio names. May contain duplicates for slightly different writings. All studios names are lower case. + * @since 18.4.0 + */ + val studios: HashSet<Studio> + get() = _studios.toHashSet() + + /** + * List of names of producers. May contain duplicates for slightly different writings. All producer names are lower case. + * @since 18.4.0 + */ + val producers: HashSet<Producer> + get() = _producers.toHashSet() + + /** + * Duplicate-free list of links to related anime including the kind of relation. One entry per link. + * @since 20.0.0 + */ + val relatedAnime: HashSet<RelatedAnime> + get() = _relatedAnime.toHashSet() + + /** + * Duplicate-free list of tags. This contains both genres and tags from metadata providers. All tags are lower case. + * @since 16.6.0 + */ + val tags: HashSet<Tag> + get() = _tags.toHashSet() + + /** + * Duplicate-free list of scores. + * @since 17.0.0 + */ + val scores: HashSet<MetaDataProviderScoreValue> + get() = metaDataProviderScores.values.toHashSet() + + init { + require(title.neitherNullNorBlank()) { "Title cannot be blank." } + + if (activateChecks) { + performChecks() + } + } + + /** + * Add additional synonyms to the existing list. Duplicates are being ignored. + * Comparison for this is case-sensitive. This will **not** override [synonyms]. + * The value which is present in [title] cannot be added. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but + * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. + * @since 3.1.0 + * @param synonym Synonyms to be added. + * @return Same instance. + */ + public fun addSynonyms(vararg synonym: Title): AnimeRaw = addSynonyms(synonym.toHashSet()) + + /** + * Add additional synonyms to the existing list. Duplicates are being ignored. + * Comparison for this is case-sensitive. This will **not** override [synonyms]. + * The value which is present in [title] cannot be added. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Acceptable is [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY], but + * every other type of apostrophe is replaced with [CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY]. + * @since 1.0.0 + * @param synonyms List of synonyms. + * @return Same instance. + */ + public fun addSynonyms(synonyms: Collection<Title>): AnimeRaw { + synonyms.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .filter { it != _title } + .map { normalizeApostrophes(it, CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY) } + .forEach { _synonyms.add(it) } + + return this + } + + /** + * Removes a [Title] from [synonyms] if the given condition matches. + * @since 19.2.0 + * @param condition If an entry in [synonyms] matches this condition, then the [Title] will be removed from [synonyms]. + * @return Same instance. + */ + public fun removeSynonymIf(condition: (Title) -> Boolean): AnimeRaw { + _synonyms.removeIf { condition.invoke(it) } + return this + } + + /** + * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. + * To further prevent duplicates, the longest studio name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param studios Studio names to be added. + * @return Same instance. + */ + public fun addStudios(vararg studios: Studio): AnimeRaw = addStudios(studios.toHashSet()) + + /** + * Add additional studio names to the existing list. Studios are added in lower case. Duplicates are being ignored. + * To further prevent duplicates, the longest studio name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param studios List of studio names to be added. + * @return Same instance. + */ + public fun addStudios(studios: Collection<Studio>): AnimeRaw { + val newNormalized = studios.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .map { it.lowercase() } + .map { normalizeApostrophes(it) } + .toSet() + + val union =_studios.toHashSet().union(newNormalized).sortedBy { it.length } + + val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.startsWith(check) } + .any { lookup -> lookup.length > check.length } + }.filterNot { check -> // prevent duplicates with same suffix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.endsWith(check) } + .any { lookup -> lookup.length > check.length } + } + + _studios.clear() + removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _studios.add(it) } + + return this + } + + /** + * Add additional names of producers to the existing list. Producers are added in lower case. + * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param producers names of producers to be added. + * @return Same instance. + */ + public fun addProducers(vararg producers: Producer): AnimeRaw = addProducers(producers.toHashSet()) + + /** + * Add additional names of producers to the existing list. Producers are added in lower case. + * Duplicates are being ignored. To further prevent duplicates, the longest producer name wins. Example: + * When adding `satelight` and `satelight inc.` only `satelight inc.` will remain, because `satelight` is identified + * as the shorter version of `satelight inc.`. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 18.4.0 + * @param producers List of names of producers to be added. + * @return Same instance. + */ + public fun addProducers(producers: Collection<Producer>): AnimeRaw { + val newNormalized = producers.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .map { it.lowercase() } + .map { normalizeApostrophes(it) } + .toSet() + + val union =_producers.toHashSet().union(newNormalized).sortedBy { it.length } + + val cleanedDuplicatesFirstStage = union.filterNot { check -> // prevent duplicates with same prefix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.startsWith(check) } + .any { lookup -> lookup.length > check.length } + }.filterNot { check -> // prevent duplicates with same suffix + union.filter { lookup -> lookup != check } + .filter { lookup -> lookup.endsWith(check) } + .any { lookup -> lookup.length > check.length } + } + + _producers.clear() + removeOverlappingDuplicates(cleanedDuplicatesFirstStage).forEach { _producers.add(it) } + + return this + } + + /** + * Add additional sources to the existing list. This will **not** override [sources]. + * Duplicates are being ignored. + * @since 3.1.0 + * @param source Sources to be added. + * @return Same instance. + */ + public fun addSources(vararg source: URI): AnimeRaw = addSources(source.toHashSet()) + + /** + * Add additional sources to the existing list. This will **not** override [sources]. + * Duplicates are being ignored. + * @since 3.0.0 + * @param sources List of sources. + * @return Same instance. + */ + public fun addSources(sources: Collection<URI>): AnimeRaw { + _sources.addAll(sources) + + removeRelatedAnimeIf { sources.contains(it) } + + return this + } + + /** + * Removes an [URI] from [sources] if the given condition matches. + * @since 16.6.0 + * @param condition If an entry in [sources] matches this condition, then the [URI] will be removed from [sources]. + * @return Same instance. + */ + public fun removeSourceIf(condition: (URI) -> Boolean): AnimeRaw { + _sources.removeIf { condition.invoke(it) } + return this + } + + /** + * Add additional related anime to the existing list. This will **not** override [relatedAnime]. + * Links which are part of [sources] are ignored. There is only one entry per link: an entry having a + * [RelatedAnime.relation] replaces an existing entry without one, otherwise the existing entry is kept. + * @since 20.0.0 + * @param relatedAnime Related anime including the kind of relation. + * @return Same instance. + */ + public fun addRelatedAnime(vararg relatedAnime: RelatedAnime): AnimeRaw = addRelatedAnime(relatedAnime.toList()) + + /** + * Add additional related anime to the existing list. This will **not** override [relatedAnime]. + * Links which are part of [sources] are ignored. There is only one entry per link: an entry having a + * [RelatedAnime.relation] replaces an existing entry without one, otherwise the existing entry is kept. + * @since 20.0.0 + * @param relatedAnime List of related anime including the kind of relation. + * @return Same instance. + */ + public fun addRelatedAnime(relatedAnime: Collection<RelatedAnime>): AnimeRaw { + relatedAnime.asSequence() + .filter { !_sources.contains(it.uri) } + .forEach { new -> + val existing = _relatedAnime.firstOrNull { it.uri == new.uri } + when { + existing == null -> _relatedAnime.add(new) + existing.relation == null && new.relation != null -> { + _relatedAnime.remove(existing) + _relatedAnime.add(new) + } + } + } + + return this + } + + /** + * Add additional related anime for which the kind of relation is unknown. + * This will **not** override [relatedAnime]. See [addRelatedAnime]. + * @since 11.0.0 + * @param relatedAnime Links to related anime. + * @return Same instance. + */ + public fun addRelatedAnime(vararg relatedAnime: URI): AnimeRaw = addRelatedAnime(relatedAnime.map { RelatedAnime(it) }) + + /** + * Add additional related anime for which the kind of relation is unknown. + * This will **not** override [relatedAnime]. See [addRelatedAnime]. + * @since 11.0.0 + * @param relatedAnime Links to related anime. + * @return Same instance. + */ + @JvmName("addRelatedAnimeUris") + public fun addRelatedAnime(relatedAnime: Collection<URI>): AnimeRaw = addRelatedAnime(relatedAnime.map { RelatedAnime(it) }) + + /** + * Removes an entry from [relatedAnime] if the given condition matches its link. + * @since 11.0.0 + * @param condition If the link of an entry in [relatedAnime] matches this condition, then the entry will be removed from [relatedAnime]. + * @return Same instance. + */ + public fun removeRelatedAnimeIf(condition: (URI) -> Boolean): AnimeRaw { + _relatedAnime.removeIf { condition.invoke(it.uri) } + return this + } + + /** + * Add additional tags to the existing list. This will **not** override [tags]. + * Duplicates are being ignored. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 3.1.0 + * @param tags List of tags. + * @return Same instance. + */ + public fun addTags(vararg tags: Tag): AnimeRaw = addTags(tags.toHashSet()) + + /** + * Add additional tags to the existing list. This will **not** override [tags]. + * Duplicates are being ignored. + * Values are normalized using [normalize]. The usage of apostrophes is also normalized for cases with a + * preceding s and a following as. Examples `'s` or `s'`. Every type of apostrophe is replaced with + * [SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY]. + * @since 1.0.0 + * @param tags List of tags. + * @return Same instance. + */ + public fun addTags(tags: Collection<Tag>): AnimeRaw { + tags.asSequence() + .map { it.normalize() } + .filter { it.neitherNullNorBlank() } + .map { it.lowercase() } + .map { normalizeApostrophes(it) } + .forEach { _tags.add(it) } + + return this + } + + /** + * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. + * Only one instance of a score per metadata provider is supported. Latest score added wins. + * @since 17.0.0 + * @param scores List of scores provided by metadata providers. + * @return Same instance. + */ + public fun addScores(vararg scores: MetaDataProviderScore): AnimeRaw = addScores(scores.toHashSet()) + + /** + * Adds scores provided by metadata providers. Ignores [NoMetaDataProviderScore]. + * Only one instance of a score per metadata provider is supported. Latest score added wins. + * @since 17.0.0 + * @param scores A score of a metadata provider. + * @return Same instance. + */ + public fun addScores(scores: Collection<MetaDataProviderScore>): AnimeRaw { + scores.filterIsInstance<MetaDataProviderScoreValue>().forEach { score -> + metaDataProviderScores[score.hostname] = score + } + + return this + } + + /** + * + Title and synonyms of the given [AnimeRaw] will both be added to the [synonyms] of this instance. + * + All sources of the given [AnimeRaw] will be added to the [sources] of this instance. + * + All related anime of the given [AnimeRaw] will be added to the [relatedAnime] of this instance. + * + All tags of the given [AnimeRaw] will be added to the [tags] of this instance. + * + All studios of the given [AnimeRaw] will be added to the [studios] of this instance. + * + All producers of the given [AnimeRaw] will be added to the [producers] of this instance. + * + In case the number of episodes of this instance is 0, the value of the given [AnimeRaw] will be applied. + * + In case the type of this instance is [AnimeType.UNKNOWN], the value of the given [AnimeRaw] will be applied. + * + In case the status of this instance is [AnimeStatus.UNKNOWN], the value of the given [AnimeRaw] will be applied. + * + In case the duration of this instance is [Duration.UNKNOWN], the value of the given [AnimeRaw] will be applied. + * + In case the season of this instance's [animeSeason] is [UNDEFINED], the season of the given [AnimeRaw] will be applied. + * + In case the year of this instance's [animeSeason] is [AnimeSeason.UNKNOWN_YEAR], the year if the given [AnimeRaw] will be applied. + * + All scores are collected. However, only one instance per metadata provider is kept. Latest score added wins. + * @since 1.0.0 + * @param anime [AnimeRaw] which is being merged into this instance. + * @return New instance of the merged anime. + */ + public fun mergeWith(anime: AnimeRaw): AnimeRaw { + val mergedEpisodes = if (episodes == 0 && anime.episodes != 0) { + anime.episodes + } else { + episodes + } + + val mergedType = if (type == UNKNOWN_TYPE && anime.type != UNKNOWN_TYPE) { + anime.type + } else { + type + } + + val mergedStatus = if (status == UNKNOWN_STATUS && anime.status != UNKNOWN_STATUS) { + anime.status + } else { + status + } + + val mergedDuration = if (duration == UNKNOWN_DURATION && anime.duration != UNKNOWN_DURATION) { + anime.duration + } else { + duration + } + + val mergedSeason = if (animeSeason.season == UNDEFINED && anime.animeSeason.season != UNDEFINED) { + anime.animeSeason.season + } else { + animeSeason.season + } + + val mergedYear = if (animeSeason.isYearOfPremiereUnknown() && anime.animeSeason.isYearOfPremiereKnown()) { + anime.animeSeason.year + } else { + animeSeason.year + } + + return AnimeRaw( + _title = title, + type = mergedType, + episodes = mergedEpisodes, + status = mergedStatus, + picture = picture, + thumbnail = thumbnail, + duration = mergedDuration, + animeSeason = AnimeSeason( + season = mergedSeason, + year = mergedYear, + ), + ).addSources(sources) + .addSources(anime.sources) + .addSynonyms(_synonyms) + .addSynonyms(anime.title) + .addSynonyms(anime.synonyms) + .addRelatedAnime(_relatedAnime) + .addRelatedAnime(anime.relatedAnime) + .addTags(_tags) + .addTags(anime.tags) + .addStudios(_studios) + .addStudios(anime.studios) + .addProducers(_producers) + .addProducers(anime.producers) + .addScores(metaDataProviderScores.values) + .addScores(anime.scores) + } + + /** + * Performs checks and fixes data. + * @since 11.0.0 + * @return Same instance of the anime. + * @throws IllegalArgumentException if _title is blank or number of episodes is negative. + */ + public fun performChecks(): AnimeRaw { + _title = _title.normalize() + require(_title.neitherNullNorBlank()) { "Title cannot be blank." } + + require(episodes >= 0) { "Episodes cannot have a negative value." } + + val uncheckedSources: Collection<URI> = _sources.toSet() + _sources.clear() + addSources(uncheckedSources) + + val uncheckedSynonyms: Collection<Title> = _synonyms.toSet() + _synonyms.clear() + addSynonyms(uncheckedSynonyms) + + val uncheckedStudios: Collection<Title> = _studios.toSet() + _studios.clear() + addStudios(uncheckedStudios) + + val uncheckedProducers: Collection<Title> = _producers.toSet() + _producers.clear() + addProducers(uncheckedProducers) + + val uncheckedRelatedAnime: Collection<RelatedAnime> = _relatedAnime.toSet() + _relatedAnime.clear() + addRelatedAnime(uncheckedRelatedAnime) + + val uncheckedTags: Collection<Tag> = _tags.toList() + _tags.clear() + addTags(uncheckedTags) + + return this + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AnimeRaw + + if (_title != other._title) return false + if (_sources != other.sources) return false + if (type != other.type) return false + if (episodes != other.episodes) return false + if (status != other.status) return false + if (animeSeason != other.animeSeason) return false + if (picture != other.picture) return false + if (thumbnail != other.thumbnail) return false + if (duration != other.duration) return false + if (scores != other.scores) return false + if (_synonyms != other.synonyms) return false + if (_studios != other.studios) return false + if (_producers != other.producers) return false + if (_relatedAnime != other.relatedAnime) return false + if (_tags != other.tags) return false + + return true + } + + override fun hashCode(): Int { + var result = _title.hashCode() + result = 31 * result + _sources.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + episodes + result = 31 * result + status.hashCode() + result = 31 * result + animeSeason.hashCode() + result = 31 * result + picture.hashCode() + result = 31 * result + thumbnail.hashCode() + result = 31 * result + duration.hashCode() + result = 31 * result + metaDataProviderScores.values.sorted().hashCode() + result = 31 * result + _synonyms.hashCode() + result = 31 * result + _studios.hashCode() + result = 31 * result + _producers.hashCode() + result = 31 * result + _relatedAnime.hashCode() + result = 31 * result + _tags.hashCode() + return result + } + + override fun toString(): String { + return """ + AnimeRaw( + sources = ${_sources.sorted()} + title = $_title + type = $type + episodes = $episodes + status = $status + animeSeason = $animeSeason + picture = $picture + thumbnail = $thumbnail + duration = $duration + scores = ${metaDataProviderScores.values.sortedBy { it.hostname }} + synonyms = ${_synonyms.sorted()} + studios = ${_studios.sorted()} + producers = ${_producers.sorted()} + relatedAnime = ${_relatedAnime.sorted()} + tags = ${_tags.sorted()} + ) + """.trimIndent() + } + + private fun removeOverlappingDuplicates(list: Collection<String>): Collection<String> { + if (list.isEmpty() || list.size == 1) { + return list + } + + val ret = ConcurrentHashMap.newKeySet<String>(list.size).apply { addAll(list) } + var lastSize: Int + + do { + lastSize = ret.size + + for (outerName in ret) { + val last = outerName.split(' ').lastOrNull() ?: continue + + for (innerName in ret) { + val first = innerName.split(' ').firstOrNull() ?: continue + + if (last == first && outerName != innerName && last !in falsePositives) { + when { + outerName.length > innerName.length -> ret.remove(innerName) + else -> ret.remove(outerName) + } + } + } + } + } while (lastSize > ret.size) + + return ret + } + + private fun normalizeApostrophes(value: String, normalizeTo: Char = SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY): String { + var normalized = value + + val apostropheAsSuffix = when { + normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX + else -> APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX + } + + if (apostropheAsSuffix.containsMatchIn(normalized)) { + apostropheAsSuffix.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> + normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) + } + } + + val apostropheUsage = when { + normalizeTo == SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY -> APOSTROPH_USAGE_INCLUDING_U2019_REGEX + else -> APOSTROPH_USAGE_EXCEPT_U2019_REGEX + } + + if (apostropheUsage.containsMatchIn(normalized)) { + apostropheUsage.findAll(normalized).map { it.groups["toBeReplaced"]!!.value }.forEach { toBeReplaced -> + normalized = normalized.replace(toBeReplaced, normalizeTo.toString()) + } + } + + return normalized + } + + private companion object { + private const val CORRECT_APOSTROPHE_INCLUDING_TYPOGRAPHY: Char = '\u2019' + private const val SIMPLIFIED_APOSTROPHE_NO_TYPOGRAPHY: Char = '\u0027' + private val APOSTROPH_SUFFIX_TO_S_EXCEPT_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() + private val APOSTROPH_SUFFIX_TO_S_INCLUDING_U2019_REGEX: Regex = """s(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060]) *""".toRegex() + private val APOSTROPH_USAGE_EXCEPT_U2019_REGEX: Regex = """(?<toBeReplaced>[\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() + private val APOSTROPH_USAGE_INCLUDING_U2019_REGEX: Regex = """(?<toBeReplaced>[\u2019\u02BB\u02BC\u2018\u275B\u275C\u02B9\u02BE\u02C8\u055A\u07F4\u07F5\u1FBF\u2032\uA78C\uFF07\u0060])[a-z]""".toRegex() + private val falsePositives = hashSetOf<String>( + "anime", + "studio", + "tv", + "tokyo", + "groove", + "media", + "production", + "animation", + "television", + "group", + ) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt index 0a242a2f..6c099591 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/Descendant.kt @@ -1,124 +1,124 @@ -package io.github.manamiproject.modb.core.extractor - -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank - - -internal class Descendant(private val initial: String) { - - /** - * First part up to the first child element if it exists. - */ - private val selfPath: String = initial.trimStart('/').substringBefore('/').ifBlank { - initial.trimStart('/') - } - - val terminatingChild: String by lazy { - initial.removePrefix("/") - .removePrefix("/") - .removePrefix(selfPath) - .removePrefix("/") - .split('/') - .last() - } - - fun hasTerminatingChild(): Boolean = terminatingChild.neitherNullNorBlank() && (terminatingChild in TERMINATING_FUNCTIONS || terminatingChild.startsWith('@')) - - fun isTerminatingChild(): Boolean = selfPath.neitherNullNorBlank() && (selfPath in TERMINATING_FUNCTIONS || selfPath.startsWith('@')) - - private fun allFilters(value: String): List<String> { - return ALL_FILTERS.findAll(value) - .map { it.value } - .toList() - } - - private fun transformFilters(value: String): String { - var ret = value - - allFilters(ret).forEach { - val replacement = when { - FILTER_ATTRIBUTE_EQUALS_VALUE.matches(it) -> { // [@attr='value'] - val matchResult = FILTER_ATTRIBUTE_EQUALS_VALUE.matchEntire(it)!! - "[${matchResult.groups["attr"]!!.value}=${matchResult.groups["value"]!!.value}]" - } - - FILTER_ATTRIBUTE_CONTAINS.matches(it) -> { // [contains(@attr, 'value')] - val matchResult = FILTER_ATTRIBUTE_CONTAINS.matchEntire(it)!! - "[${matchResult.groups["attr"]!!.value}*=${matchResult.groups["value"]!!.value}]" - } - - FILTER_TEXT_CONTAINS.matches(it) -> { // [contains(text(), 'value')] - val matchResult = FILTER_TEXT_CONTAINS.matchEntire(it)!! - ":matchesOwn(${matchResult.groups["value"]!!.value})" - } - - FILTER_TEXT_EQUALS.matches(it) -> { // [text()='value'] - val matchResult = FILTER_TEXT_EQUALS.matchEntire(it)!! - ":matchesOwn(^${matchResult.groups["value"]!!.value}$)" - } - - FILTER_SELECT_SIBLING.matches(it) -> { // [1] - val matchResult = FILTER_SELECT_SIBLING.matchEntire(it)!! - ":eq(${matchResult.groups["number"]!!.value})" - } - - FILTER_ATTRIBUTE_EXISTS.matches(it) -> { // [@attr] - val matchResult = FILTER_ATTRIBUTE_EXISTS.matchEntire(it)!! - "[${matchResult.groups["attr"]!!.value}]" - } - - else -> throw IllegalStateException("No transformation for [$it]") - } - - ret = ret.replace(it, replacement) - } - - return ret - } - - fun toJsoup(): String { - var converted = initial.removePrefix("/") - .removePrefix("/") - - if (hasTerminatingChild()) { - converted = initial.removePrefix("/") - .removePrefix("/") - .split('/') - .toMutableList() - .apply { removeLast() } - .joinToString("/") - } - - val replacements = STRING_VALUES.findAll(converted) - .map { it.groups } - .map { it["stringValue"] } - .filterNotNull() - .mapIndexed { index, matchGroup -> - "'#|STRVAL$index|#'" to "'${matchGroup.value}'" - } - .toList() - - converted = replacements.fold(converted) { acc, pair -> - acc.replace(pair.second, pair.first) - }.replace("/", " > ") - - replacements.toMap().forEach { (replacement, original) -> - converted = converted.replace(replacement, original) - } - - converted = transformFilters(converted).replace("> following-sibling::", "~ ") - - return converted - } - - private companion object { - private val TERMINATING_FUNCTIONS = setOf("text()", "node()", "html()") - private val ALL_FILTERS = """\[(.*?)\]""".toRegex() - private val FILTER_ATTRIBUTE_EQUALS_VALUE = """^\[@(?<attr>[\w-]+) *= *'(?<value>.*?)'\]$""".toRegex() - private val FILTER_ATTRIBUTE_CONTAINS = """^\[contains\(@(?<attr>[\w-]+) *, *'(?<value>.*?)'\)\]$""".toRegex() - private val FILTER_TEXT_CONTAINS = """^\[contains\(text\(\) *, *'(?<value>.*?)'\)\]$""".toRegex() - private val FILTER_TEXT_EQUALS = """^\[text\(\) *= *'(?<value>.*?)'\]$""".toRegex() - private val FILTER_SELECT_SIBLING = """^\[(?<number>\d+)\]$""".toRegex() - private val FILTER_ATTRIBUTE_EXISTS = """^\[@(?<attr>[\w-]+)\]$""".toRegex() - private val STRING_VALUES = """.*?'(?<stringValue>.*?)'.*?""".toRegex() - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.extractor + +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank + + +internal class Descendant(private val initial: String) { + + /** + * First part up to the first child element if it exists. + */ + private val selfPath: String = initial.trimStart('/').substringBefore('/').ifBlank { + initial.trimStart('/') + } + + val terminatingChild: String by lazy { + initial.removePrefix("/") + .removePrefix("/") + .removePrefix(selfPath) + .removePrefix("/") + .split('/') + .last() + } + + fun hasTerminatingChild(): Boolean = terminatingChild.neitherNullNorBlank() && (terminatingChild in TERMINATING_FUNCTIONS || terminatingChild.startsWith('@')) + + fun isTerminatingChild(): Boolean = selfPath.neitherNullNorBlank() && (selfPath in TERMINATING_FUNCTIONS || selfPath.startsWith('@')) + + private fun allFilters(value: String): List<String> { + return ALL_FILTERS.findAll(value) + .map { it.value } + .toList() + } + + private fun transformFilters(value: String): String { + var ret = value + + allFilters(ret).forEach { + val replacement = when { + FILTER_ATTRIBUTE_EQUALS_VALUE.matches(it) -> { // [@attr='value'] + val matchResult = FILTER_ATTRIBUTE_EQUALS_VALUE.matchEntire(it)!! + "[${matchResult.groups["attr"]!!.value}=${matchResult.groups["value"]!!.value}]" + } + + FILTER_ATTRIBUTE_CONTAINS.matches(it) -> { // [contains(@attr, 'value')] + val matchResult = FILTER_ATTRIBUTE_CONTAINS.matchEntire(it)!! + "[${matchResult.groups["attr"]!!.value}*=${matchResult.groups["value"]!!.value}]" + } + + FILTER_TEXT_CONTAINS.matches(it) -> { // [contains(text(), 'value')] + val matchResult = FILTER_TEXT_CONTAINS.matchEntire(it)!! + ":matchesOwn(${matchResult.groups["value"]!!.value})" + } + + FILTER_TEXT_EQUALS.matches(it) -> { // [text()='value'] + val matchResult = FILTER_TEXT_EQUALS.matchEntire(it)!! + ":matchesOwn(^${matchResult.groups["value"]!!.value}$)" + } + + FILTER_SELECT_SIBLING.matches(it) -> { // [1] + val matchResult = FILTER_SELECT_SIBLING.matchEntire(it)!! + ":eq(${matchResult.groups["number"]!!.value})" + } + + FILTER_ATTRIBUTE_EXISTS.matches(it) -> { // [@attr] + val matchResult = FILTER_ATTRIBUTE_EXISTS.matchEntire(it)!! + "[${matchResult.groups["attr"]!!.value}]" + } + + else -> throw IllegalStateException("No transformation for [$it]") + } + + ret = ret.replace(it, replacement) + } + + return ret + } + + fun toJsoup(): String { + var converted = initial.removePrefix("/") + .removePrefix("/") + + if (hasTerminatingChild()) { + converted = initial.removePrefix("/") + .removePrefix("/") + .split('/') + .toMutableList() + .apply { removeLast() } + .joinToString("/") + } + + val replacements = STRING_VALUES.findAll(converted) + .map { it.groups } + .map { it["stringValue"] } + .filterNotNull() + .mapIndexed { index, matchGroup -> + "'#|STRVAL$index|#'" to "'${matchGroup.value}'" + } + .toList() + + converted = replacements.fold(converted) { acc, pair -> + acc.replace(pair.second, pair.first) + }.replace("/", " > ") + + replacements.toMap().forEach { (replacement, original) -> + converted = converted.replace(replacement, original) + } + + converted = transformFilters(converted).replace("> following-sibling::", "~ ") + + return converted + } + + private companion object { + private val TERMINATING_FUNCTIONS = setOf("text()", "node()", "html()") + private val ALL_FILTERS = """\[(.*?)\]""".toRegex() + private val FILTER_ATTRIBUTE_EQUALS_VALUE = """^\[@(?<attr>[\w-]+) *= *'(?<value>.*?)'\]$""".toRegex() + private val FILTER_ATTRIBUTE_CONTAINS = """^\[contains\(@(?<attr>[\w-]+) *, *'(?<value>.*?)'\)\]$""".toRegex() + private val FILTER_TEXT_CONTAINS = """^\[contains\(text\(\) *, *'(?<value>.*?)'\)\]$""".toRegex() + private val FILTER_TEXT_EQUALS = """^\[text\(\) *= *'(?<value>.*?)'\]$""".toRegex() + private val FILTER_SELECT_SIBLING = """^\[(?<number>\d+)\]$""".toRegex() + private val FILTER_ATTRIBUTE_EXISTS = """^\[@(?<attr>[\w-]+)\]$""".toRegex() + private val STRING_VALUES = """.*?'(?<stringValue>.*?)'.*?""".toRegex() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt index 8ade5665..2cdab4d8 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/extractor/JsoupXPathDataExtractor.kt @@ -1,67 +1,67 @@ -package io.github.manamiproject.modb.core.extractor - -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import kotlin.reflect.full.isSubclassOf - -internal object JsoupXPathDataExtractor : DataExtractor { - - override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult { - val document = Jsoup.parse(rawContent) - - val result: Map<OutputKey, Any> = selection.map { transformXpathToTriple(it) } - .map { elementSelection(document, it) } - .associate { toOutput(it) } - - return ExtractionResult(result) - } - - private fun toOutput(item: Pair<OutputKey, Any>) : Pair<OutputKey, Any> { - if (item.second::class.isSubclassOf(Iterable::class)) { - val castedList = (item.second as Iterable<*>).toList() - - return if (castedList.size == 1) { - item.first to castedList.first()!! - } else { - item.first to castedList - } - } else { - return item - } - } - - private fun transformXpathToTriple(entry: Map.Entry<OutputKey, Selector>): Triple<OutputKey, Selector, String> { - val split = entry.value.split('/') - val allExceptLast = split.subList(0, split.size-1).joinToString("/") - - return when { - split.last().startsWith("@") -> Triple(entry.key, allExceptLast, split.last().trimStart('@')) - split.last().endsWith("text()") -> Triple(entry.key, allExceptLast, split.last()) - split.last().endsWith("node()") -> Triple(entry.key, allExceptLast, split.last()) - split.last().endsWith("html()") -> Triple(entry.key, allExceptLast, split.last()) - else -> Triple(entry.key, entry.value, EMPTY) - } - } - - private fun elementSelection(document: Document, entry: Triple<OutputKey, Selector, String>): Pair<OutputKey, Any> { - val jsoupElements = if (entry.second.neitherNullNorBlank()) { - document.selectXpath(entry.second) - } else { - document.allElements - } - - val value = when { - jsoupElements.isEmpty() -> NotFound - entry.third == "text()" -> jsoupElements.eachText() ?: NotFound - entry.third == "node()" -> jsoupElements.dataNodes().map { it.wholeData.trim() } - entry.third == "html()" -> jsoupElements.map { it.outerHtml() } - entry.third.eitherNullOrBlank() -> jsoupElements.textNodes().map { it.text().trim() }.filter { it.neitherNullNorBlank() } - else -> jsoupElements.eachAttr(entry.third) ?: NotFound - } - - return entry.first to value - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.extractor + +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import kotlin.reflect.full.isSubclassOf + +internal object JsoupXPathDataExtractor : DataExtractor { + + override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult { + val document = Jsoup.parse(rawContent) + + val result: Map<OutputKey, Any> = selection.map { transformXpathToTriple(it) } + .map { elementSelection(document, it) } + .associate { toOutput(it) } + + return ExtractionResult(result) + } + + private fun toOutput(item: Pair<OutputKey, Any>) : Pair<OutputKey, Any> { + if (item.second::class.isSubclassOf(Iterable::class)) { + val castedList = (item.second as Iterable<*>).toList() + + return if (castedList.size == 1) { + item.first to castedList.first()!! + } else { + item.first to castedList + } + } else { + return item + } + } + + private fun transformXpathToTriple(entry: Map.Entry<OutputKey, Selector>): Triple<OutputKey, Selector, String> { + val split = entry.value.split('/') + val allExceptLast = split.subList(0, split.size-1).joinToString("/") + + return when { + split.last().startsWith("@") -> Triple(entry.key, allExceptLast, split.last().trimStart('@')) + split.last().endsWith("text()") -> Triple(entry.key, allExceptLast, split.last()) + split.last().endsWith("node()") -> Triple(entry.key, allExceptLast, split.last()) + split.last().endsWith("html()") -> Triple(entry.key, allExceptLast, split.last()) + else -> Triple(entry.key, entry.value, EMPTY) + } + } + + private fun elementSelection(document: Document, entry: Triple<OutputKey, Selector, String>): Pair<OutputKey, Any> { + val jsoupElements = if (entry.second.neitherNullNorBlank()) { + document.selectXpath(entry.second) + } else { + document.allElements + } + + val value = when { + jsoupElements.isEmpty() -> NotFound + entry.third == "text()" -> jsoupElements.eachText() ?: NotFound + entry.third == "node()" -> jsoupElements.dataNodes().map { it.wholeData.trim() } + entry.third == "html()" -> jsoupElements.map { it.outerHtml() } + entry.third.eitherNullOrBlank() -> jsoupElements.textNodes().map { it.text().trim() }.filter { it.neitherNullNorBlank() } + else -> jsoupElements.eachAttr(entry.third) ?: NotFound + } + + return entry.first to value + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt index 12defd85..dadc12f8 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeAdapter.kt @@ -1,219 +1,219 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.* -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.extensions.EMPTY -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeAdapter: JsonAdapter<Anime>() { - - private val titleAdapter = TitleAdapter() - private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) - private val uriAdapter = UriAdapter() - private val typeAdapter = AnimeTypeAdapter() - private val statusAdapter = AnimeStatusAdapter() - private val animeSeasonAdapter = AnimeSeasonAdapter() - private val durationAdapter = DurationAdapter() - private val scoreAdapter = ScoreAdapter() - private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) - private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) - private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) - private val relatedAnimeHashSetAdapter = HashSetAdapter(RelatedAnimeAdapter()) - private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) - - @FromJson - override fun fromJson(reader: JsonReader): Anime { - reader.beginObject() - - var title = EMPTY - var titleDeserialized = false - var sources = HashSet<URI>() - var sourcesDeserialized = false - var type = UNKNOWN_TYPE - var typeDeserialized = false - var episodes = 0 - var episodesDeserialized = false - var status = UNKNOWN_STATUS - var statusDeserialized = false - var animeSeason = AnimeSeason() - var animeSeasonDeserialized = false - var picture = NO_PICTURE - var pictureDeserialized = false - var thumbnail = NO_PICTURE_THUMBNAIL - var thumbnailDeserialized = false - var duration = UNKNOWN_DURATION - var score: Score = NoScore - var synonyms = HashSet<Title>() - var synonymsDeserialized = false - var studios = HashSet<String>() - var studiosDeserialized = false - var producers = HashSet<String>() - var producersDeserialized = false - var relatedAnime = HashSet<RelatedAnime>() - var relatedAnimeDeserialized = false - var tags = HashSet<Tag>() - var tagsDeserialized = false - - while (reader.hasNext()) { - when (reader.nextName()) { - "sources" -> { - sources = uriHashSetAdapter.fromJson(reader) - sourcesDeserialized = true - } - "title" -> { - title = titleAdapter.fromJson(reader) - titleDeserialized = true - } - "type" -> { - type = typeAdapter.fromJson(reader) - typeDeserialized = true - } - "episodes" -> { - episodes = reader.nextInt() - episodesDeserialized = true - } - "status" -> { - status = statusAdapter.fromJson(reader) - statusDeserialized = true - } - "animeSeason" -> { - animeSeason = animeSeasonAdapter.fromJson(reader) - animeSeasonDeserialized = true - } - "picture" -> { - picture = uriAdapter.fromJson(reader) - pictureDeserialized = true - } - "thumbnail" -> { - thumbnail = uriAdapter.fromJson(reader) - thumbnailDeserialized = true - } - "duration" -> { - duration = durationAdapter.fromJson(reader) - } - "score" -> { - score = scoreAdapter.fromJson(reader) - } - "synonyms" -> { - synonyms = titleHashSetAdapter.fromJson(reader) - synonymsDeserialized = true - } - "studios" -> { - studios = studioHashSetAdapter.fromJson(reader) - studiosDeserialized = true - } - "producers" -> { - producers = producerHashSetAdapter.fromJson(reader) - producersDeserialized = true - } - "relatedAnime" -> { - relatedAnime = relatedAnimeHashSetAdapter.fromJson(reader) - relatedAnimeDeserialized = true - } - "tags" -> { - tags = tagHashSetAdapter.fromJson(reader) - tagsDeserialized = true - } - else -> reader.skipValue() - } - } - - reader.endObject() - - when { - !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") - !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") - !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") - !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") - !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") - !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") - !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") - !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") - !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") - !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") - !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") - !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") - !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") - } - - return Anime( - title = title, - sources = sources, - type = type, - episodes = episodes, - status = status, - animeSeason = animeSeason, - picture = picture, - thumbnail = thumbnail, - duration = duration, - score = score, - synonyms = synonyms, - studios = studios, - producers = producers, - relatedAnime = relatedAnime, - tags = tags, - ) - } - - @ToJson - override fun toJson(writer: JsonWriter, value: Anime?) { - requireNotNull(value) { "AnimeAdapter expects non-nullable value, but received null." } - - writer.beginObject() - - writer.name("sources") - uriHashSetAdapter.toJson(writer, value.sources) - - writer.name("title") - titleAdapter.toJson(writer, value.title) - - writer.name("type") - typeAdapter.toJson(writer, value.type) - - writer.name("episodes").value(value.episodes) - - writer.name("status") - statusAdapter.toJson(writer, value.status) - - writer.name("animeSeason") - animeSeasonAdapter.toJson(writer, value.animeSeason) - - writer.name("picture") - uriAdapter.toJson(writer, value.picture) - - writer.name("thumbnail") - uriAdapter.toJson(writer, value.thumbnail) - - if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { - writer.name("duration") - durationAdapter.toJson(writer, value.duration) - } - - if (value.score != NoScore || (value.score == NoScore && writer.serializeNulls)) { - writer.name("score") - scoreAdapter.toJson(writer, value.score) - } - - writer.name("synonyms") - titleHashSetAdapter.toJson(writer, value.synonyms) - - writer.name("studios") - studioHashSetAdapter.toJson(writer, value.studios) - - writer.name("producers") - producerHashSetAdapter.toJson(writer, value.producers) - - writer.name("relatedAnime") - relatedAnimeHashSetAdapter.toJson(writer, value.relatedAnime) - - writer.name("tags") - titleHashSetAdapter.toJson(writer, value.tags) - - writer.endObject() - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.* +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.extensions.EMPTY +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeAdapter: JsonAdapter<Anime>() { + + private val titleAdapter = TitleAdapter() + private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) + private val uriAdapter = UriAdapter() + private val typeAdapter = AnimeTypeAdapter() + private val statusAdapter = AnimeStatusAdapter() + private val animeSeasonAdapter = AnimeSeasonAdapter() + private val durationAdapter = DurationAdapter() + private val scoreAdapter = ScoreAdapter() + private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) + private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) + private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) + private val relatedAnimeHashSetAdapter = HashSetAdapter(RelatedAnimeAdapter()) + private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) + + @FromJson + override fun fromJson(reader: JsonReader): Anime { + reader.beginObject() + + var title = EMPTY + var titleDeserialized = false + var sources = HashSet<URI>() + var sourcesDeserialized = false + var type = UNKNOWN_TYPE + var typeDeserialized = false + var episodes = 0 + var episodesDeserialized = false + var status = UNKNOWN_STATUS + var statusDeserialized = false + var animeSeason = AnimeSeason() + var animeSeasonDeserialized = false + var picture = NO_PICTURE + var pictureDeserialized = false + var thumbnail = NO_PICTURE_THUMBNAIL + var thumbnailDeserialized = false + var duration = UNKNOWN_DURATION + var score: Score = NoScore + var synonyms = HashSet<Title>() + var synonymsDeserialized = false + var studios = HashSet<String>() + var studiosDeserialized = false + var producers = HashSet<String>() + var producersDeserialized = false + var relatedAnime = HashSet<RelatedAnime>() + var relatedAnimeDeserialized = false + var tags = HashSet<Tag>() + var tagsDeserialized = false + + while (reader.hasNext()) { + when (reader.nextName()) { + "sources" -> { + sources = uriHashSetAdapter.fromJson(reader) + sourcesDeserialized = true + } + "title" -> { + title = titleAdapter.fromJson(reader) + titleDeserialized = true + } + "type" -> { + type = typeAdapter.fromJson(reader) + typeDeserialized = true + } + "episodes" -> { + episodes = reader.nextInt() + episodesDeserialized = true + } + "status" -> { + status = statusAdapter.fromJson(reader) + statusDeserialized = true + } + "animeSeason" -> { + animeSeason = animeSeasonAdapter.fromJson(reader) + animeSeasonDeserialized = true + } + "picture" -> { + picture = uriAdapter.fromJson(reader) + pictureDeserialized = true + } + "thumbnail" -> { + thumbnail = uriAdapter.fromJson(reader) + thumbnailDeserialized = true + } + "duration" -> { + duration = durationAdapter.fromJson(reader) + } + "score" -> { + score = scoreAdapter.fromJson(reader) + } + "synonyms" -> { + synonyms = titleHashSetAdapter.fromJson(reader) + synonymsDeserialized = true + } + "studios" -> { + studios = studioHashSetAdapter.fromJson(reader) + studiosDeserialized = true + } + "producers" -> { + producers = producerHashSetAdapter.fromJson(reader) + producersDeserialized = true + } + "relatedAnime" -> { + relatedAnime = relatedAnimeHashSetAdapter.fromJson(reader) + relatedAnimeDeserialized = true + } + "tags" -> { + tags = tagHashSetAdapter.fromJson(reader) + tagsDeserialized = true + } + else -> reader.skipValue() + } + } + + reader.endObject() + + when { + !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") + !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") + !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") + !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") + !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") + !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") + !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") + !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") + !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") + !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") + !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") + !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") + !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") + } + + return Anime( + title = title, + sources = sources, + type = type, + episodes = episodes, + status = status, + animeSeason = animeSeason, + picture = picture, + thumbnail = thumbnail, + duration = duration, + score = score, + synonyms = synonyms, + studios = studios, + producers = producers, + relatedAnime = relatedAnime, + tags = tags, + ) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: Anime?) { + requireNotNull(value) { "AnimeAdapter expects non-nullable value, but received null." } + + writer.beginObject() + + writer.name("sources") + uriHashSetAdapter.toJson(writer, value.sources) + + writer.name("title") + titleAdapter.toJson(writer, value.title) + + writer.name("type") + typeAdapter.toJson(writer, value.type) + + writer.name("episodes").value(value.episodes) + + writer.name("status") + statusAdapter.toJson(writer, value.status) + + writer.name("animeSeason") + animeSeasonAdapter.toJson(writer, value.animeSeason) + + writer.name("picture") + uriAdapter.toJson(writer, value.picture) + + writer.name("thumbnail") + uriAdapter.toJson(writer, value.thumbnail) + + if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { + writer.name("duration") + durationAdapter.toJson(writer, value.duration) + } + + if (value.score != NoScore || (value.score == NoScore && writer.serializeNulls)) { + writer.name("score") + scoreAdapter.toJson(writer, value.score) + } + + writer.name("synonyms") + titleHashSetAdapter.toJson(writer, value.synonyms) + + writer.name("studios") + studioHashSetAdapter.toJson(writer, value.studios) + + writer.name("producers") + producerHashSetAdapter.toJson(writer, value.producers) + + writer.name("relatedAnime") + relatedAnimeHashSetAdapter.toJson(writer, value.relatedAnime) + + writer.name("tags") + titleHashSetAdapter.toJson(writer, value.tags) + + writer.endObject() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt index ddaa7887..0b406ba3 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapter.kt @@ -1,221 +1,221 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.* -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.extensions.EMPTY -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeRawAdapter: JsonAdapter<AnimeRaw>() { - - private val titleAdapter = TitleAdapter() - private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) - private val uriAdapter = UriAdapter() - private val typeAdapter = AnimeTypeAdapter() - private val statusAdapter = AnimeStatusAdapter() - private val animeSeasonAdapter = AnimeSeasonAdapter() - private val durationAdapter = DurationAdapter() - private val metaDataProviderScoreValueAdapter = HashSetAdapter(MetaDataProviderScoreValueAdapter()) - private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) - private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) - private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) - private val relatedAnimeHashSetAdapter = HashSetAdapter(RelatedAnimeAdapter()) - private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) - - @FromJson - override fun fromJson(reader: JsonReader): AnimeRaw { - reader.beginObject() - - var title = EMPTY - var titleDeserialized = false - var sources = HashSet<URI>() - var sourcesDeserialized = false - var type = UNKNOWN_TYPE - var typeDeserialized = false - var episodes = 0 - var episodesDeserialized = false - var status = UNKNOWN_STATUS - var statusDeserialized = false - var animeSeason = AnimeSeason() - var animeSeasonDeserialized = false - var picture = NO_PICTURE - var pictureDeserialized = false - var thumbnail = NO_PICTURE_THUMBNAIL - var thumbnailDeserialized = false - var duration = UNKNOWN_DURATION - var scores = HashSet<MetaDataProviderScoreValue>() - var synonyms = HashSet<Title>() - var synonymsDeserialized = false - var studios = HashSet<String>() - var studiosDeserialized = false - var producers = HashSet<String>() - var producersDeserialized = false - var relatedAnime = HashSet<RelatedAnime>() - var relatedAnimeDeserialized = false - var tags = HashSet<Tag>() - var tagsDeserialized = false - - while (reader.hasNext()) { - when (reader.nextName()) { - "sources" -> { - sources = uriHashSetAdapter.fromJson(reader) - sourcesDeserialized = true - } - "title" -> { - title = titleAdapter.fromJson(reader) - titleDeserialized = true - } - "type" -> { - type = typeAdapter.fromJson(reader) - typeDeserialized = true - } - "episodes" -> { - episodes = reader.nextInt() - episodesDeserialized = true - } - "status" -> { - status = statusAdapter.fromJson(reader) - statusDeserialized = true - } - "animeSeason" -> { - animeSeason = animeSeasonAdapter.fromJson(reader) - animeSeasonDeserialized = true - } - "picture" -> { - picture = uriAdapter.fromJson(reader) - pictureDeserialized = true - } - "thumbnail" -> { - thumbnail = uriAdapter.fromJson(reader) - thumbnailDeserialized = true - } - "duration" -> { - duration = durationAdapter.fromJson(reader) - } - "scores" -> { - scores = metaDataProviderScoreValueAdapter.fromJson(reader) - } - "synonyms" -> { - synonyms = titleHashSetAdapter.fromJson(reader) - synonymsDeserialized = true - } - "studios" -> { - studios = titleHashSetAdapter.fromJson(reader) - studiosDeserialized = true - } - "producers" -> { - producers = titleHashSetAdapter.fromJson(reader) - producersDeserialized = true - } - "relatedAnime" -> { - relatedAnime = relatedAnimeHashSetAdapter.fromJson(reader) - relatedAnimeDeserialized = true - } - "tags" -> { - tags = tagHashSetAdapter.fromJson(reader) - tagsDeserialized = true - } - else -> reader.skipValue() - } - } - - reader.endObject() - - when { - !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") - !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") - !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") - !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") - !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") - !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") - !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") - !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") - !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") - !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") - !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") - !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") - !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") - } - - return AnimeRaw( - _title = title, - _sources = sources, - type = type, - episodes = episodes, - status = status, - animeSeason = animeSeason, - picture = picture, - thumbnail = thumbnail, - duration = duration, - _synonyms = synonyms, - _studios = studios, - _producers = producers, - _relatedAnime = relatedAnime, - _tags = tags, - activateChecks = false, - ).addScores(scores) - } - - @ToJson - override fun toJson(writer: JsonWriter, value: AnimeRaw?) { - requireNotNull(value) { "AnimeRawAdapter expects non-nullable value, but received null." } - - if (!value.activateChecks) { - value.performChecks() - } - - writer.beginObject() - - writer.name("sources") - uriHashSetAdapter.toJson(writer, value.sources) - - writer.name("title") - titleAdapter.toJson(writer, value.title) - - writer.name("type") - typeAdapter.toJson(writer, value.type) - - writer.name("episodes").value(value.episodes) - - writer.name("status") - statusAdapter.toJson(writer, value.status) - - writer.name("animeSeason") - animeSeasonAdapter.toJson(writer, value.animeSeason) - - writer.name("picture") - uriAdapter.toJson(writer, value.picture) - - writer.name("thumbnail") - uriAdapter.toJson(writer, value.thumbnail) - - if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { - writer.name("duration") - durationAdapter.toJson(writer, value.duration) - } - - writer.name("scores") - metaDataProviderScoreValueAdapter.toJson(writer, value.scores) - - writer.name("synonyms") - titleHashSetAdapter.toJson(writer, value.synonyms) - - writer.name("studios") - studioHashSetAdapter.toJson(writer, value.studios) - - writer.name("producers") - producerHashSetAdapter.toJson(writer, value.producers) - - writer.name("relatedAnime") - relatedAnimeHashSetAdapter.toJson(writer, value.relatedAnime) - - writer.name("tags") - tagHashSetAdapter.toJson(writer, value.tags) - - writer.endObject() - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.* +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.extensions.EMPTY +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeRawAdapter: JsonAdapter<AnimeRaw>() { + + private val titleAdapter = TitleAdapter() + private val titleHashSetAdapter = HashSetAdapter(TitleAdapter()) + private val uriAdapter = UriAdapter() + private val typeAdapter = AnimeTypeAdapter() + private val statusAdapter = AnimeStatusAdapter() + private val animeSeasonAdapter = AnimeSeasonAdapter() + private val durationAdapter = DurationAdapter() + private val metaDataProviderScoreValueAdapter = HashSetAdapter(MetaDataProviderScoreValueAdapter()) + private val studioHashSetAdapter = HashSetAdapter(StudioAdapter()) + private val producerHashSetAdapter = HashSetAdapter(ProducerAdapter()) + private val uriHashSetAdapter = HashSetAdapter(UriAdapter()) + private val relatedAnimeHashSetAdapter = HashSetAdapter(RelatedAnimeAdapter()) + private val tagHashSetAdapter = HashSetAdapter(TagAdapter()) + + @FromJson + override fun fromJson(reader: JsonReader): AnimeRaw { + reader.beginObject() + + var title = EMPTY + var titleDeserialized = false + var sources = HashSet<URI>() + var sourcesDeserialized = false + var type = UNKNOWN_TYPE + var typeDeserialized = false + var episodes = 0 + var episodesDeserialized = false + var status = UNKNOWN_STATUS + var statusDeserialized = false + var animeSeason = AnimeSeason() + var animeSeasonDeserialized = false + var picture = NO_PICTURE + var pictureDeserialized = false + var thumbnail = NO_PICTURE_THUMBNAIL + var thumbnailDeserialized = false + var duration = UNKNOWN_DURATION + var scores = HashSet<MetaDataProviderScoreValue>() + var synonyms = HashSet<Title>() + var synonymsDeserialized = false + var studios = HashSet<String>() + var studiosDeserialized = false + var producers = HashSet<String>() + var producersDeserialized = false + var relatedAnime = HashSet<RelatedAnime>() + var relatedAnimeDeserialized = false + var tags = HashSet<Tag>() + var tagsDeserialized = false + + while (reader.hasNext()) { + when (reader.nextName()) { + "sources" -> { + sources = uriHashSetAdapter.fromJson(reader) + sourcesDeserialized = true + } + "title" -> { + title = titleAdapter.fromJson(reader) + titleDeserialized = true + } + "type" -> { + type = typeAdapter.fromJson(reader) + typeDeserialized = true + } + "episodes" -> { + episodes = reader.nextInt() + episodesDeserialized = true + } + "status" -> { + status = statusAdapter.fromJson(reader) + statusDeserialized = true + } + "animeSeason" -> { + animeSeason = animeSeasonAdapter.fromJson(reader) + animeSeasonDeserialized = true + } + "picture" -> { + picture = uriAdapter.fromJson(reader) + pictureDeserialized = true + } + "thumbnail" -> { + thumbnail = uriAdapter.fromJson(reader) + thumbnailDeserialized = true + } + "duration" -> { + duration = durationAdapter.fromJson(reader) + } + "scores" -> { + scores = metaDataProviderScoreValueAdapter.fromJson(reader) + } + "synonyms" -> { + synonyms = titleHashSetAdapter.fromJson(reader) + synonymsDeserialized = true + } + "studios" -> { + studios = titleHashSetAdapter.fromJson(reader) + studiosDeserialized = true + } + "producers" -> { + producers = titleHashSetAdapter.fromJson(reader) + producersDeserialized = true + } + "relatedAnime" -> { + relatedAnime = relatedAnimeHashSetAdapter.fromJson(reader) + relatedAnimeDeserialized = true + } + "tags" -> { + tags = tagHashSetAdapter.fromJson(reader) + tagsDeserialized = true + } + else -> reader.skipValue() + } + } + + reader.endObject() + + when { + !titleDeserialized -> throw IllegalStateException("Property 'title' is either missing or null.") + !sourcesDeserialized -> throw IllegalStateException("Property 'sources' is either missing or null.") + !episodesDeserialized -> throw IllegalStateException("Property 'episodes' is either missing or null.") + !typeDeserialized -> throw IllegalStateException("Property 'type' is either missing or null.") + !statusDeserialized -> throw IllegalStateException("Property 'status' is either missing or null.") + !animeSeasonDeserialized -> throw IllegalStateException("Property 'animeSeason' is either missing or null.") + !pictureDeserialized -> throw IllegalStateException("Property 'picture' is either missing or null.") + !thumbnailDeserialized -> throw IllegalStateException("Property 'thumbnail' is either missing or null.") + !synonymsDeserialized -> throw IllegalStateException("Property 'synonyms' is either missing or null.") + !studiosDeserialized -> throw IllegalStateException("Property 'studios' is either missing or null.") + !producersDeserialized -> throw IllegalStateException("Property 'producers' is either missing or null.") + !relatedAnimeDeserialized -> throw IllegalStateException("Property 'relatedAnime' is either missing or null.") + !tagsDeserialized -> throw IllegalStateException("Property 'tags' is either missing or null.") + } + + return AnimeRaw( + _title = title, + _sources = sources, + type = type, + episodes = episodes, + status = status, + animeSeason = animeSeason, + picture = picture, + thumbnail = thumbnail, + duration = duration, + _synonyms = synonyms, + _studios = studios, + _producers = producers, + _relatedAnime = relatedAnime, + _tags = tags, + activateChecks = false, + ).addScores(scores) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: AnimeRaw?) { + requireNotNull(value) { "AnimeRawAdapter expects non-nullable value, but received null." } + + if (!value.activateChecks) { + value.performChecks() + } + + writer.beginObject() + + writer.name("sources") + uriHashSetAdapter.toJson(writer, value.sources) + + writer.name("title") + titleAdapter.toJson(writer, value.title) + + writer.name("type") + typeAdapter.toJson(writer, value.type) + + writer.name("episodes").value(value.episodes) + + writer.name("status") + statusAdapter.toJson(writer, value.status) + + writer.name("animeSeason") + animeSeasonAdapter.toJson(writer, value.animeSeason) + + writer.name("picture") + uriAdapter.toJson(writer, value.picture) + + writer.name("thumbnail") + uriAdapter.toJson(writer, value.thumbnail) + + if (value.duration.duration != 0 || (value.duration.duration == 0 && writer.serializeNulls)) { + writer.name("duration") + durationAdapter.toJson(writer, value.duration) + } + + writer.name("scores") + metaDataProviderScoreValueAdapter.toJson(writer, value.scores) + + writer.name("synonyms") + titleHashSetAdapter.toJson(writer, value.synonyms) + + writer.name("studios") + studioHashSetAdapter.toJson(writer, value.studios) + + writer.name("producers") + producerHashSetAdapter.toJson(writer, value.producers) + + writer.name("relatedAnime") + relatedAnimeHashSetAdapter.toJson(writer, value.relatedAnime) + + writer.name("tags") + tagHashSetAdapter.toJson(writer, value.tags) + + writer.endObject() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt index 8b6d6784..19ab05cb 100644 --- a/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt +++ b/core/src/main/kotlin/io/github/manamiproject/modb/core/json/Json.kt @@ -1,118 +1,118 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.Moshi -import com.squareup.moshi.adapter -import com.squareup.moshi.addAdapter -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_FS -import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL -import kotlinx.coroutines.withContext -import okio.buffer -import okio.source -import com.squareup.moshi.JsonAdapter as MoshiAdapter - -/** - * Handles serialization and deserialization of objects to/from JSON. - * @since 11.0.0 - */ -public object Json { - - private const val JSON_IDENT: String = " " - - @PublishedApi - @OptIn(ExperimentalStdlibApi::class) - internal val moshi: Moshi = Moshi.Builder() - .addAdapter(UriAdapter()) - .addAdapter(RelatedAnimeAdapter()) - .addAdapter(DurationAdapter()) - .addAdapter(AnimeTypeAdapter()) - .addAdapter(AnimeStatusAdapter()) - .addAdapter(AnimeSeasonAdapter()) - .addAdapter(AnimeRawAdapter()) - .addAdapter(AnimeAdapter()) - .addAdapter(ScoreAdapter()) - .addAdapter(MetaDataProviderScoreValueAdapter()) - .addLast(KotlinJsonAdapterFactory()) - .build() - - /** - * Parse a [String] into an object. - * - * **WARNING** [Collection]s of a non-nullable type can still contain null. - * @since 8.0.0 - * @param json Valid JSON as [String]. - * @return Deserialized JSON as object of given type [T]. - */ - @OptIn(ExperimentalStdlibApi::class) - public suspend inline fun <reified T> parseJson(json: String): T? = withContext(LIMITED_CPU) { - return@withContext moshi.adapter<T>().nullSafe().fromJson(json) - } - - /** - * Parse an [LifecycleAwareInputStream] into an object. - * - * **WARNING** [Collection]s of a non-nullable type can still contain null. - * @since 8.0.0 - * @param json Valid JSON as [LifecycleAwareInputStream]. - * @return Deserialized JSON as object of given type [T]. - */ - @OptIn(ExperimentalStdlibApi::class) - public suspend inline fun <reified T> parseJson(json: LifecycleAwareInputStream): T? = withContext(LIMITED_FS) { - return@withContext moshi.adapter<T>().nullSafe().fromJson(json.source().buffer()) - } - - /** - * Serialize any object to JSON. Uses default set of custom adapters for serializing [AnimeRaw] and [Anime]. - * @since 8.0.0 - * @param obj Any object that is supposed to be serialized to JSON. - * @param options Options that can change the default behavior of the JSON serialization. - * @return Given object serialized in JSON as [String]. - */ - public suspend fun toJson(obj: Any, vararg options: SerializationOptions): String = withContext(LIMITED_CPU) { - return@withContext configureJsonAdapter(SerializationSettings(options.toSet())).toJson(obj) - } - - @OptIn(ExperimentalStdlibApi::class) - private fun configureJsonAdapter(settings: SerializationSettings): MoshiAdapter<Any> { - var jsonAdapter = moshi.adapter<Any>() - - if (settings.serializeNullActivated) { - jsonAdapter = jsonAdapter.serializeNulls() - } - - if (settings.prettyPrintActivated) { - jsonAdapter = jsonAdapter.indent(JSON_IDENT) - } - - return jsonAdapter - } - - /** - * Possible options to customize serialization of JSON documents using [Json.toJson]. - * @since 11.0.0 - */ - public enum class SerializationOptions { - /** - * By default, the output JSON string is formatted. Using this option will create a minified string instead. - * @since 11.0.0 - */ - DEACTIVATE_PRETTY_PRINT, - /** - * By default, a property providing `null` as value will be serialized. Using this option will omit these properties. - * @since 11.0.0 - */ - DEACTIVATE_SERIALIZE_NULL; - } -} - -private class SerializationSettings(val options: Set<Json.SerializationOptions>) { - /** Pretty-print is activated by default. */ - val prettyPrintActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_PRETTY_PRINT)} ?: true - /** Serialize null is activated by default. */ - val serializeNullActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_SERIALIZE_NULL)} ?: true -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.squareup.moshi.addAdapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_FS +import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL +import kotlinx.coroutines.withContext +import okio.buffer +import okio.source +import com.squareup.moshi.JsonAdapter as MoshiAdapter + +/** + * Handles serialization and deserialization of objects to/from JSON. + * @since 11.0.0 + */ +public object Json { + + private const val JSON_IDENT: String = " " + + @PublishedApi + @OptIn(ExperimentalStdlibApi::class) + internal val moshi: Moshi = Moshi.Builder() + .addAdapter(UriAdapter()) + .addAdapter(RelatedAnimeAdapter()) + .addAdapter(DurationAdapter()) + .addAdapter(AnimeTypeAdapter()) + .addAdapter(AnimeStatusAdapter()) + .addAdapter(AnimeSeasonAdapter()) + .addAdapter(AnimeRawAdapter()) + .addAdapter(AnimeAdapter()) + .addAdapter(ScoreAdapter()) + .addAdapter(MetaDataProviderScoreValueAdapter()) + .addLast(KotlinJsonAdapterFactory()) + .build() + + /** + * Parse a [String] into an object. + * + * **WARNING** [Collection]s of a non-nullable type can still contain null. + * @since 8.0.0 + * @param json Valid JSON as [String]. + * @return Deserialized JSON as object of given type [T]. + */ + @OptIn(ExperimentalStdlibApi::class) + public suspend inline fun <reified T> parseJson(json: String): T? = withContext(LIMITED_CPU) { + return@withContext moshi.adapter<T>().nullSafe().fromJson(json) + } + + /** + * Parse an [LifecycleAwareInputStream] into an object. + * + * **WARNING** [Collection]s of a non-nullable type can still contain null. + * @since 8.0.0 + * @param json Valid JSON as [LifecycleAwareInputStream]. + * @return Deserialized JSON as object of given type [T]. + */ + @OptIn(ExperimentalStdlibApi::class) + public suspend inline fun <reified T> parseJson(json: LifecycleAwareInputStream): T? = withContext(LIMITED_FS) { + return@withContext moshi.adapter<T>().nullSafe().fromJson(json.source().buffer()) + } + + /** + * Serialize any object to JSON. Uses default set of custom adapters for serializing [AnimeRaw] and [Anime]. + * @since 8.0.0 + * @param obj Any object that is supposed to be serialized to JSON. + * @param options Options that can change the default behavior of the JSON serialization. + * @return Given object serialized in JSON as [String]. + */ + public suspend fun toJson(obj: Any, vararg options: SerializationOptions): String = withContext(LIMITED_CPU) { + return@withContext configureJsonAdapter(SerializationSettings(options.toSet())).toJson(obj) + } + + @OptIn(ExperimentalStdlibApi::class) + private fun configureJsonAdapter(settings: SerializationSettings): MoshiAdapter<Any> { + var jsonAdapter = moshi.adapter<Any>() + + if (settings.serializeNullActivated) { + jsonAdapter = jsonAdapter.serializeNulls() + } + + if (settings.prettyPrintActivated) { + jsonAdapter = jsonAdapter.indent(JSON_IDENT) + } + + return jsonAdapter + } + + /** + * Possible options to customize serialization of JSON documents using [Json.toJson]. + * @since 11.0.0 + */ + public enum class SerializationOptions { + /** + * By default, the output JSON string is formatted. Using this option will create a minified string instead. + * @since 11.0.0 + */ + DEACTIVATE_PRETTY_PRINT, + /** + * By default, a property providing `null` as value will be serialized. Using this option will omit these properties. + * @since 11.0.0 + */ + DEACTIVATE_SERIALIZE_NULL; + } +} + +private class SerializationSettings(val options: Set<Json.SerializationOptions>) { + /** Pretty-print is activated by default. */ + val prettyPrintActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_PRETTY_PRINT)} ?: true + /** Serialize null is activated by default. */ + val serializeNullActivated: Boolean = false.takeIf { options.contains(DEACTIVATE_SERIALIZE_NULL)} ?: true +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt index 9c1ee9b5..332a5ebd 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/TestingAssets.kt @@ -1,1280 +1,1280 @@ -package io.github.manamiproject.modb.core - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.* -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.OutputKey -import io.github.manamiproject.modb.core.extractor.Selector -import io.github.manamiproject.modb.core.logging.Logger -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import org.slf4j.Marker -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.net.URI -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -import kotlin.reflect.* -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import org.slf4j.Logger as Slf4jLogger - -internal object TestMetaDataProviderConfig : MetaDataProviderConfig { - override fun isTestContext(): Boolean = true - override fun hostname(): Hostname = shouldNotBeInvoked() - override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() - override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() - override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() -} - -internal object TestAnimeConverter : AnimeConverter { - override suspend fun convert(rawContent: String) = shouldNotBeInvoked() -} - -internal object TestDataExtractor : DataExtractor { - override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() -} - -internal object TestConfigRegistry: ConfigRegistry { - override fun string(key: String): String = shouldNotBeInvoked() - override fun long(key: String): Long = shouldNotBeInvoked() - override fun int(key: String): Int = shouldNotBeInvoked() - override fun <T: Any> list(key: String): List<T> = shouldNotBeInvoked() - override fun boolean(key: String): Boolean = shouldNotBeInvoked() - override fun double(key: String): Double = shouldNotBeInvoked() - override fun localDate(key: String): LocalDate = shouldNotBeInvoked() - override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() - override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() - override fun <T: Any> map(key: String): Map<String, T> = shouldNotBeInvoked() -} - -internal class TestKProperty<T>: KProperty<T> { - override val annotations: List<Annotation> - get() = shouldNotBeInvoked() - override val getter: KProperty.Getter<T> - get() = shouldNotBeInvoked() - override val isAbstract: Boolean - get() = shouldNotBeInvoked() - override val isConst: Boolean - get() = shouldNotBeInvoked() - override val isFinal: Boolean - get() = shouldNotBeInvoked() - override val isLateinit: Boolean - get() = shouldNotBeInvoked() - override val isOpen: Boolean - get() = shouldNotBeInvoked() - override val isSuspend: Boolean - get() = shouldNotBeInvoked() - override val name: String - get() = shouldNotBeInvoked() - override val parameters: List<KParameter> - get() = shouldNotBeInvoked() - override val returnType: KType - get() = shouldNotBeInvoked() - override val typeParameters: List<KTypeParameter> - get() = shouldNotBeInvoked() - override val visibility: KVisibility - get() = shouldNotBeInvoked() - override fun call(vararg args: Any?): T = shouldNotBeInvoked() - override fun callBy(args: Map<KParameter, Any?>): T = shouldNotBeInvoked() -} - -internal object TestScoreCalculator: ScoreCalculator { - override fun calculateScore(scores: Collection<MetaDataProviderScore>): Score = shouldNotBeInvoked() -} - -internal object TestLoggerImplementation: Logger { - override fun error(message: () -> String) = shouldNotBeInvoked() - override fun error(exception: Throwable, message: () -> String) = shouldNotBeInvoked() - override fun warn(message: () -> String) = shouldNotBeInvoked() - override fun warn(exception: Throwable, message: () -> String) = shouldNotBeInvoked() - override fun info(message: () -> String) = shouldNotBeInvoked() - override fun debug(message: () -> String) = shouldNotBeInvoked() - override fun trace(message: () -> String) = shouldNotBeInvoked() -} - -internal object TestSlf4jImplementation: Slf4jLogger { - override fun getName(): String = shouldNotBeInvoked() - override fun isTraceEnabled(): Boolean = shouldNotBeInvoked() - override fun isTraceEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun trace(msg: String?) = shouldNotBeInvoked() - override fun trace(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun trace(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun trace(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun trace(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, format: String?, vararg argArray: Any?) = shouldNotBeInvoked() - override fun trace(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isDebugEnabled(): Boolean = shouldNotBeInvoked() - override fun isDebugEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun debug(msg: String?) = shouldNotBeInvoked() - override fun debug(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun debug(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun debug(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun debug(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun debug(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isInfoEnabled(): Boolean = shouldNotBeInvoked() - override fun isInfoEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun info(msg: String?) = shouldNotBeInvoked() - override fun info(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun info(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun info(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun info(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun info(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun info(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun info(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun info(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun info(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isWarnEnabled(): Boolean = shouldNotBeInvoked() - override fun isWarnEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun warn(msg: String?) = shouldNotBeInvoked() - override fun warn(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun warn(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun warn(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun warn(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun warn(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun isErrorEnabled(): Boolean = shouldNotBeInvoked() - override fun isErrorEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() - override fun error(msg: String?) = shouldNotBeInvoked() - override fun error(format: String?, arg: Any?) = shouldNotBeInvoked() - override fun error(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun error(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun error(msg: String?, t: Throwable?) = shouldNotBeInvoked() - override fun error(marker: Marker?, msg: String?) = shouldNotBeInvoked() - override fun error(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() - override fun error(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() - override fun error(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() - override fun error(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() -} - -@Suppress("unused") -internal object TestAnimeRawObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: AnimeRaw - get() = AnimeRaw("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - _synonyms = hashSetOf( - "DN", - "デスノート", - ), - _studios = hashSetOf( - "madhouse", - ), - _producers = hashSetOf( - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - _tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Go-toubun no Hanayome *", - _sources = hashSetOf( - URI("https://anilist.co/anime/177191"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/131520")), - ), - _tags = hashSetOf( - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anilist.co/anime/177191" - ], - "title": "Go-toubun no Hanayome *", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - } - ], - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://anilist.co/anime/131520" - } - ], - "tags": [ - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "5-toubun no Hanayome*", - _sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/16165")), - RelatedAnime(URI("https://anilist.co/anime/131520")), - RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), - RelatedAnime(URI("https://animecountdown.com/1577789")), - RelatedAnime(URI("https://anisearch.com/anime/16091")), - RelatedAnime(URI("https://kitsu.app/anime/44229")), - RelatedAnime(URI("https://livechart.me/anime/10488")), - RelatedAnime(URI("https://livechart.me/anime/11921")), - RelatedAnime(URI("https://livechart.me/anime/3448")), - RelatedAnime(URI("https://livechart.me/anime/9428")), - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - RelatedAnime(URI("https://simkl.com/anime/1577789")), - ), - _tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anidb.net", - value = 6.63, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "anime-planet.com", - value = 3.845, - range = 0.5..5.0, - ), - MetaDataProviderScoreValue( - hostname = "anisearch.com", - value = 3.9, - range = 0.1..5.0, - ), - MetaDataProviderScoreValue( - hostname = "kitsu.app", - value = 76.95, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "livechart.me", - value = 8.35, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "simkl.com", - value = 7.9, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anidb.net", - "value": 6.63, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "anime-planet.com", - "value": 3.845, - "range": { - "minInclusive": 0.5, - "maxInclusive": 5.0 - } - }, - { - "hostname": "anisearch.com", - "value": 3.9, - "range": { - "minInclusive": 0.1, - "maxInclusive": 5.0 - } - }, - { - "hostname": "kitsu.app", - "value": 76.95, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "livechart.me", - "value": 8.35, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "simkl.com", - "value": 7.9, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/16165" - }, - { - "uri": "https://anilist.co/anime/131520" - }, - { - "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" - }, - { - "uri": "https://animecountdown.com/1577789" - }, - { - "uri": "https://anisearch.com/anime/16091" - }, - { - "uri": "https://kitsu.app/anime/44229" - }, - { - "uri": "https://livechart.me/anime/10488" - }, - { - "uri": "https://livechart.me/anime/11921" - }, - { - "uri": "https://livechart.me/anime/3448" - }, - { - "uri": "https://livechart.me/anime/9428" - }, - { - "uri": "https://myanimelist.net/anime/48548" - }, - { - "uri": "https://simkl.com/anime/1577789" - } - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -@Suppress("unused") -internal object TestAnimeObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: Anime - get() = Anime("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "score": null, - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: Anime - get() = Anime( - title = "Death Note", - sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - score = NoScore, - synonyms = hashSetOf( - "DN", - "デスノート", - ), - studios = hashSetOf( - "madhouse", - ), - producers = hashSetOf( - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "score": null, - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://myanimelist.net/anime/58755"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.44, - arithmeticMean = 7.44, - median = 7.44, - ), - synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - ), - tags = hashSetOf( - "comedy", - "harem", - "romance", - "school", - "shounen", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/58755" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.44, - "arithmeticMean": 7.44, - "median": 7.44 - }, - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/48548" - } - ], - "tags": [ - "comedy", - "harem", - "romance", - "school", - "shounen" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.616175305477198, - arithmeticMean = 7.624601113172541, - median = 7.7272727272727275, - ), - synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - "tbs", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/16165")), - RelatedAnime(URI("https://anilist.co/anime/131520")), - RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), - RelatedAnime(URI("https://animecountdown.com/1577789")), - RelatedAnime(URI("https://anisearch.com/anime/16091")), - RelatedAnime(URI("https://kitsu.app/anime/44229")), - RelatedAnime(URI("https://livechart.me/anime/10488")), - RelatedAnime(URI("https://livechart.me/anime/11921")), - RelatedAnime(URI("https://livechart.me/anime/3448")), - RelatedAnime(URI("https://livechart.me/anime/9428")), - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - RelatedAnime(URI("https://simkl.com/anime/1577789")), - ), - tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ) - - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.616175305477198, - "arithmeticMean": 7.624601113172541, - "median": 7.7272727272727275 - }, - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon", - "tbs" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/16165" - }, - { - "uri": "https://anilist.co/anime/131520" - }, - { - "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" - }, - { - "uri": "https://animecountdown.com/1577789" - }, - { - "uri": "https://anisearch.com/anime/16091" - }, - { - "uri": "https://kitsu.app/anime/44229" - }, - { - "uri": "https://livechart.me/anime/10488" - }, - { - "uri": "https://livechart.me/anime/11921" - }, - { - "uri": "https://livechart.me/anime/3448" - }, - { - "uri": "https://livechart.me/anime/9428" - }, - { - "uri": "https://myanimelist.net/anime/48548" - }, - { - "uri": "https://simkl.com/anime/1577789" - } - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { - private var closed = false - - override fun read(): Int { - if (closed) throw IOException("Stream closed") - return delegate.read() - } - - override fun close() { - closed = true - delegate.close() - } -} - -internal open class TestInputStream: InputStream() { - override fun close(): Unit = shouldNotBeInvoked() - override fun read(): Int = shouldNotBeInvoked() - override fun read(b: ByteArray?): Int = shouldNotBeInvoked() - override fun read(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() - override fun readAllBytes(): ByteArray? = shouldNotBeInvoked() - override fun readNBytes(len: Int): ByteArray? = shouldNotBeInvoked() - override fun readNBytes(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() - override fun skip(n: Long): Long = shouldNotBeInvoked() - override fun skipNBytes(n: Long): Unit = shouldNotBeInvoked() - override fun available(): Int = shouldNotBeInvoked() - override fun mark(readlimit: Int): Unit = shouldNotBeInvoked() - override fun markSupported(): Boolean = shouldNotBeInvoked() - override fun reset(): Unit = shouldNotBeInvoked() - override fun transferTo(out: OutputStream?): Long = shouldNotBeInvoked() - override fun equals(other: Any?): Boolean = shouldNotBeInvoked() - override fun hashCode(): Int = shouldNotBeInvoked() - override fun toString(): String = shouldNotBeInvoked() -} \ No newline at end of file +package io.github.manamiproject.modb.core + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.* +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.OutputKey +import io.github.manamiproject.modb.core.extractor.Selector +import io.github.manamiproject.modb.core.logging.Logger +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import org.slf4j.Marker +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.URI +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import kotlin.reflect.* +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import org.slf4j.Logger as Slf4jLogger + +internal object TestMetaDataProviderConfig : MetaDataProviderConfig { + override fun isTestContext(): Boolean = true + override fun hostname(): Hostname = shouldNotBeInvoked() + override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() + override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() + override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() +} + +internal object TestAnimeConverter : AnimeConverter { + override suspend fun convert(rawContent: String) = shouldNotBeInvoked() +} + +internal object TestDataExtractor : DataExtractor { + override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() +} + +internal object TestConfigRegistry: ConfigRegistry { + override fun string(key: String): String = shouldNotBeInvoked() + override fun long(key: String): Long = shouldNotBeInvoked() + override fun int(key: String): Int = shouldNotBeInvoked() + override fun <T: Any> list(key: String): List<T> = shouldNotBeInvoked() + override fun boolean(key: String): Boolean = shouldNotBeInvoked() + override fun double(key: String): Double = shouldNotBeInvoked() + override fun localDate(key: String): LocalDate = shouldNotBeInvoked() + override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() + override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() + override fun <T: Any> map(key: String): Map<String, T> = shouldNotBeInvoked() +} + +internal class TestKProperty<T>: KProperty<T> { + override val annotations: List<Annotation> + get() = shouldNotBeInvoked() + override val getter: KProperty.Getter<T> + get() = shouldNotBeInvoked() + override val isAbstract: Boolean + get() = shouldNotBeInvoked() + override val isConst: Boolean + get() = shouldNotBeInvoked() + override val isFinal: Boolean + get() = shouldNotBeInvoked() + override val isLateinit: Boolean + get() = shouldNotBeInvoked() + override val isOpen: Boolean + get() = shouldNotBeInvoked() + override val isSuspend: Boolean + get() = shouldNotBeInvoked() + override val name: String + get() = shouldNotBeInvoked() + override val parameters: List<KParameter> + get() = shouldNotBeInvoked() + override val returnType: KType + get() = shouldNotBeInvoked() + override val typeParameters: List<KTypeParameter> + get() = shouldNotBeInvoked() + override val visibility: KVisibility + get() = shouldNotBeInvoked() + override fun call(vararg args: Any?): T = shouldNotBeInvoked() + override fun callBy(args: Map<KParameter, Any?>): T = shouldNotBeInvoked() +} + +internal object TestScoreCalculator: ScoreCalculator { + override fun calculateScore(scores: Collection<MetaDataProviderScore>): Score = shouldNotBeInvoked() +} + +internal object TestLoggerImplementation: Logger { + override fun error(message: () -> String) = shouldNotBeInvoked() + override fun error(exception: Throwable, message: () -> String) = shouldNotBeInvoked() + override fun warn(message: () -> String) = shouldNotBeInvoked() + override fun warn(exception: Throwable, message: () -> String) = shouldNotBeInvoked() + override fun info(message: () -> String) = shouldNotBeInvoked() + override fun debug(message: () -> String) = shouldNotBeInvoked() + override fun trace(message: () -> String) = shouldNotBeInvoked() +} + +internal object TestSlf4jImplementation: Slf4jLogger { + override fun getName(): String = shouldNotBeInvoked() + override fun isTraceEnabled(): Boolean = shouldNotBeInvoked() + override fun isTraceEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun trace(msg: String?) = shouldNotBeInvoked() + override fun trace(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun trace(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun trace(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun trace(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, format: String?, vararg argArray: Any?) = shouldNotBeInvoked() + override fun trace(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isDebugEnabled(): Boolean = shouldNotBeInvoked() + override fun isDebugEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun debug(msg: String?) = shouldNotBeInvoked() + override fun debug(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun debug(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun debug(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun debug(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun debug(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isInfoEnabled(): Boolean = shouldNotBeInvoked() + override fun isInfoEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun info(msg: String?) = shouldNotBeInvoked() + override fun info(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun info(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun info(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun info(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun info(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun info(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun info(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun info(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun info(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isWarnEnabled(): Boolean = shouldNotBeInvoked() + override fun isWarnEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun warn(msg: String?) = shouldNotBeInvoked() + override fun warn(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun warn(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun warn(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun warn(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun warn(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun isErrorEnabled(): Boolean = shouldNotBeInvoked() + override fun isErrorEnabled(marker: Marker?): Boolean = shouldNotBeInvoked() + override fun error(msg: String?) = shouldNotBeInvoked() + override fun error(format: String?, arg: Any?) = shouldNotBeInvoked() + override fun error(format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun error(format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun error(msg: String?, t: Throwable?) = shouldNotBeInvoked() + override fun error(marker: Marker?, msg: String?) = shouldNotBeInvoked() + override fun error(marker: Marker?, format: String?, arg: Any?) = shouldNotBeInvoked() + override fun error(marker: Marker?, format: String?, arg1: Any?, arg2: Any?) = shouldNotBeInvoked() + override fun error(marker: Marker?, format: String?, vararg arguments: Any?) = shouldNotBeInvoked() + override fun error(marker: Marker?, msg: String?, t: Throwable?) = shouldNotBeInvoked() +} + +@Suppress("unused") +internal object TestAnimeRawObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: AnimeRaw + get() = AnimeRaw("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + _synonyms = hashSetOf( + "DN", + "デスノート", + ), + _studios = hashSetOf( + "madhouse", + ), + _producers = hashSetOf( + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + _tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Go-toubun no Hanayome *", + _sources = hashSetOf( + URI("https://anilist.co/anime/177191"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/131520")), + ), + _tags = hashSetOf( + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anilist.co/anime/177191" + ], + "title": "Go-toubun no Hanayome *", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + } + ], + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anilist.co/anime/131520" + } + ], + "tags": [ + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "5-toubun no Hanayome*", + _sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + _tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anidb.net", + value = 6.63, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "anime-planet.com", + value = 3.845, + range = 0.5..5.0, + ), + MetaDataProviderScoreValue( + hostname = "anisearch.com", + value = 3.9, + range = 0.1..5.0, + ), + MetaDataProviderScoreValue( + hostname = "kitsu.app", + value = 76.95, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "livechart.me", + value = 8.35, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "simkl.com", + value = 7.9, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anidb.net", + "value": 6.63, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "anime-planet.com", + "value": 3.845, + "range": { + "minInclusive": 0.5, + "maxInclusive": 5.0 + } + }, + { + "hostname": "anisearch.com", + "value": 3.9, + "range": { + "minInclusive": 0.1, + "maxInclusive": 5.0 + } + }, + { + "hostname": "kitsu.app", + "value": 76.95, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "livechart.me", + "value": 8.35, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "simkl.com", + "value": 7.9, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +@Suppress("unused") +internal object TestAnimeObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: Anime + get() = Anime("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "score": null, + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: Anime + get() = Anime( + title = "Death Note", + sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + score = NoScore, + synonyms = hashSetOf( + "DN", + "デスノート", + ), + studios = hashSetOf( + "madhouse", + ), + producers = hashSetOf( + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "score": null, + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://myanimelist.net/anime/58755"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.44, + arithmeticMean = 7.44, + median = 7.44, + ), + synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + ), + tags = hashSetOf( + "comedy", + "harem", + "romance", + "school", + "shounen", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/58755" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.44, + "arithmeticMean": 7.44, + "median": 7.44 + }, + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/48548" + } + ], + "tags": [ + "comedy", + "harem", + "romance", + "school", + "shounen" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.616175305477198, + arithmeticMean = 7.624601113172541, + median = 7.7272727272727275, + ), + synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + "tbs", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ) + + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.616175305477198, + "arithmeticMean": 7.624601113172541, + "median": 7.7272727272727275 + }, + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon", + "tbs" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { + private var closed = false + + override fun read(): Int { + if (closed) throw IOException("Stream closed") + return delegate.read() + } + + override fun close() { + closed = true + delegate.close() + } +} + +internal open class TestInputStream: InputStream() { + override fun close(): Unit = shouldNotBeInvoked() + override fun read(): Int = shouldNotBeInvoked() + override fun read(b: ByteArray?): Int = shouldNotBeInvoked() + override fun read(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() + override fun readAllBytes(): ByteArray? = shouldNotBeInvoked() + override fun readNBytes(len: Int): ByteArray? = shouldNotBeInvoked() + override fun readNBytes(b: ByteArray?, off: Int, len: Int): Int = shouldNotBeInvoked() + override fun skip(n: Long): Long = shouldNotBeInvoked() + override fun skipNBytes(n: Long): Unit = shouldNotBeInvoked() + override fun available(): Int = shouldNotBeInvoked() + override fun mark(readlimit: Int): Unit = shouldNotBeInvoked() + override fun markSupported(): Boolean = shouldNotBeInvoked() + override fun reset(): Unit = shouldNotBeInvoked() + override fun transferTo(out: OutputStream?): Long = shouldNotBeInvoked() + override fun equals(other: Any?): Boolean = shouldNotBeInvoked() + override fun hashCode(): Int = shouldNotBeInvoked() + override fun toString(): String = shouldNotBeInvoked() +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt index bb59ec1a..ed8cd3f8 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/anime/AnimeRawTest.kt @@ -1,9447 +1,9447 @@ -package io.github.manamiproject.modb.core.anime - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.TestAnimeRawObjects -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeStatus.ONGOING -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.json.AnimeRawAdapter -import io.github.manamiproject.modb.test.exceptionExpected -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeRawTest { - - @Nested - inner class TitleTests { - - @Test - fun `remove leading whitespace from title`() { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw(" $expectedTitle") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @Test - fun `remove tailing whitespace from title`() { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw("$expectedTitle ") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in title`() { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTitle = "Death Note" - - // when - val result = AnimeRaw("Death${input}Note") - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // when - val result = AnimeRaw("Ba${input}ek") - - // then - assertThat(result.title).isEqualTo("Baek") - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `throws exception if title is blank`(value: String) { - // when - val result = assertThrows<IllegalArgumentException> { - AnimeRaw(value) - } - - // then - assertThat(result).hasMessage("Title cannot be blank.") - } - - @Test - fun `don't change title if activateChecks is set to false`() { - // given - val expectedTitle = " Death Note " - - // when - val result = AnimeRaw( - _title = expectedTitle, - activateChecks = false, - ) - - // then - assertThat(result.title).isEqualTo(expectedTitle) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `throws an exception if title is blank even if activateChecks is set to false`(value: String) { - // when - val result = exceptionExpected<IllegalArgumentException> { - AnimeRaw( - _title = value, - activateChecks = false, - ) - } - - // then - assertThat(result).hasMessage("Title cannot be blank.") - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _synonyms = hashSetOf( - "other title1", - "other title2", - ), - ) - - // when - anime.synonyms.clear() - - // then - assertThat(anime.synonyms).isNotEmpty() - } - - @Nested - inner class AddSynonymsConstructorTests { - - @Test - fun `must not add a synonym if it equals the title`() { - // given - val title = "Death Note" - - // when - val result = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - title, - ), - ) - - // then - assertThat(result.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank synonym`(input: String) { - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Caderno${input}da${input}Morte", - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Cader${input}no da Mor${input}te", - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @Test - fun `remove leading whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - - // when - val result = AnimeRaw( - _title = "Title", - _synonyms = hashSetOf( - " $expectedTitleOne", - " $expectedTitleTwo", - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove tailing whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - - // when - val result = AnimeRaw( - _title = "Title", - _synonyms = hashSetOf( - "$expectedTitleOne ", - "$expectedTitleTwo ", - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - - // when - val result = AnimeRaw( - _title = "Title", - _synonyms = hashSetOf( - "Death Note", - "Made in Abyss", - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `successfully add a synonym`() { - // given - val synonym = "Caderno da Morte" - - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - synonym, - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - synonym, - ) - } - - @Test - fun `must not add a duplicated synonym`() { - // given - val synonym = "Caderno da Morte" - - // when - val result = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - synonym, - synonym, - ), - ) - - // then - assertThat(result.synonyms).containsExactly( - synonym, - ) - } - - @Test - fun `synonym comparison to title is not case-sensitive`() { - // given - val title = "Death Note" - - // when - val result = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - title.uppercase(), - title.lowercase(), - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - title.uppercase(), - title.lowercase(), - ) - } - - @Test - fun `synonym comparison is not case-sensitive`() { - // given - val title = "Death Note" - - // when - val result = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - title, - title.lowercase(), - title.uppercase(), - ), - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - title, - title.uppercase(), - title.lowercase(), - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `correctly normalized usages of apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _synonyms = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - "she\u2019s, John\u2019s, it\u2019s", - "the girls\u2019, the dogs\u2019", - "don\u2019t, can\u2019t", - "I\u2019m", - "I\u2019d, they\u2019d", - "rock \u2019n\u2019 roll", - "he\u2019ll, we\u2019ll", - "your\u2019re", - "I\u2019ve", - "\u2019ere", - "\u2019cause", - "\u2019bout", - "\u2019fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _synonyms = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "", " ", " ", "\u200C"]) - fun `doesn't fix synonyms if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf(value), - activateChecks = false, - ) - - // then - assertThat(obj.synonyms).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddSynonymsTests { - - @Test - fun `must not add a synonym if it equals the title`() { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms(listOf( - "Death Note", - )) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank synonym`(input: String) { - // given - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addSynonyms(listOf( - "$input $input", - )) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Caderno da Morte" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "Death${input}Note", - "Caderno${input}da${input}Morte", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Caderno da Morte" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "Death Note", - "Cader${input}no da Mor${input}te", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove leading whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - " $expectedTitleOne", - " $expectedTitleTwo", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove tailing whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "$expectedTitleOne ", - "$expectedTitleTwo ", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms(listOf( - "Death Note", - "Made in Abyss", - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `successfully add a list of synonyms`() { - // given - val one = "Caderno da Morte" - val two = "DN" - val three = "Quaderno della Morte" - val four = "Sveska Smrti" - - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms(listOf( - four, - two, - three, - one, - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - three, - four, - ) - } - - @Test - fun `must not add a duplicated synonym`() { - // given - val one = "Caderno da Morte" - val two = "DN" - - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - two, - ), - ) - - // when - anime.addSynonyms(listOf( - two, - one, - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - ) - } - - @Test - fun `synonym comparison to title is not case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw(title) - - // when - anime.addSynonyms(listOf( - title.uppercase(), - title.lowercase(), - )) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title.uppercase(), - title.lowercase(), - ) - } - - @Test - fun `synonym comparison is not case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - title.lowercase(), - title.uppercase(), - ), - ) - - // when - anime.addSynonyms(title) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title, - title.uppercase(), - title.lowercase(), - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she\u2019s, John\u2019s, it\u2019s", - "the girls\u2019, the dogs\u2019", - "don\u2019t, can\u2019t", - "I\u2019m", - "I\u2019d, they\u2019d", - "rock \u2019n\u2019 roll", - "he\u2019ll, we\u2019ll", - "your\u2019re", - "I\u2019ve", - "\u2019ere", - "\u2019cause", - "\u2019bout", - "\u2019fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddSynonymsVarargTests { - - @Test - fun `must not add a synonym if it equals the title`() { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "Death Note", - ) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank synonym`(input: String) { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "$input $input", - ) - - // then - assertThat(anime.synonyms).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "Caderno${input}da${input}Morte", - ) - - // then - assertThat(anime.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - "Cader${input}no da Mor${input}te", - ) - - // then - assertThat(anime.synonyms).containsExactly( - "Caderno da Morte", - ) - } - - @Test - fun `remove leading whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms( - " $expectedTitleOne", - " $expectedTitleTwo", - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `remove tailing whitespace from synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms( - "$expectedTitleOne ", - "$expectedTitleTwo ", - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in synonyms`() { - // given - val expectedTitleOne = "Death Note" - val expectedTitleTwo = "Made in Abyss" - val anime = AnimeRaw("Title") - - // when - anime.addSynonyms( - "Death Note", - "Made in Abyss", - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - expectedTitleOne, - expectedTitleTwo, - ) - } - - @Test - fun `successfully add synonyms`() { - // given - val one = "Caderno da Morte" - val two = "DN" - val three = "Quaderno della Morte" - val four = "Sveska Smrti" - - val anime = AnimeRaw("Death Note") - - // when - anime.addSynonyms( - four, - two, - three, - one, - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - three, - four, - ) - } - - @Test - fun `must not add a duplicated synonym`() { - // given - val one = "Caderno da Morte" - val two = "DN" - - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - two, - ), - ) - - // when - anime.addSynonyms( - two, - one, - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - one, - two, - ) - } - - @Test - fun `synonym comparison to title is case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw(title) - - // when - anime.addSynonyms( - title.uppercase(), - title.lowercase(), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title.uppercase(), - title.lowercase(), - ) - } - - @Test - fun `synonym comparison is not case-sensitive`() { - // given - val title = "Death Note" - val anime = AnimeRaw("デスノート") - - // when - anime.addSynonyms( - title, - title.lowercase(), - title.uppercase(), - ) - - // then - assertThat(anime.synonyms).containsExactlyInAnyOrder( - title, - title.uppercase(), - title.lowercase(), - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she\u2019s, John\u2019s, it\u2019s", - "the girls\u2019, the dogs\u2019", - "don\u2019t, can\u2019t", - "I\u2019m", - "I\u2019d, they\u2019d", - "rock \u2019n\u2019 roll", - "he\u2019ll, we\u2019ll", - "your\u2019re", - "I\u2019ve", - "\u2019ere", - "\u2019cause", - "\u2019bout", - "\u2019fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addSynonyms( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class RemoveSynonymIfTests { - - @Test - fun `successfully remove synonym`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Notatnik smierci", - ), - ) - - // when - anime.removeSynonymIf { it == "Notatnik smierci" } - - // then - assertThat(anime.synonyms).isEmpty() - } - - @Test - fun `don't remove anything if condition doesn't match`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Notatnik smierci", - ), - ) - - // when - anime.removeSynonymIf { it.contains("Bleach") } - - // then - assertThat(anime.synonyms).containsExactly( - "Notatnik smierci", - ) - } - } - } - - @Nested - inner class StudiosTest { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - // when - anime.studios.clear() - - // then - assertThat(anime.studios).isNotEmpty() - } - - @Nested - inner class AddStudiosConstructorTests { - - @Test - fun `studios added by constructor are set to lower case`() { - // given - val studio = "EXAMPLE" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio, - ), - ) - - // then - assertThat(result.studios).containsExactly( - studio.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from studios`() { - // given - val expectedStudio = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - " $expectedStudio", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `remove tailing whitespace from studios`() { - // given - val expectedStudio = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "$expectedStudio ", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in studios`() { - // given - val expectedStudio = "bibury animation studios" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "bibury animation studios", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.studios).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedStudio = "bibury animation studios" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "bibury${input}animation${input}studios", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedStudio = "bibury animation studios" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "bi${input}bury ani${input}mation stu${input}dios", - ), - ) - - // then - assertThat(result.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val studio = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf(), - ) - - // then - assertThat(result.studios).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val studio1 = "a studio" - val studio2 = "before the other" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val studio1 = "satelight" - val studio2 = "satelight inc." - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val studio1 = "gallop" - val studio2 = "studio gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - studio3, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "tokyo HQ gallop" - val studio4 = "gallop made up" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio2, - studio1, - studio3, - studio4, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val studio1 = "anime beans" - val studio2 = "tate anime" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val studio1 = "studio durian" - val studio2 = "wit studio" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val studio1 = "sotsu" - val studio2 = "square enix" - val studio3 = "tv aichi" - val studio4 = "tv osaka" - val studio5 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val studio1 = "tokyo kids" - val studio2 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val studio1 = "groove corporation" - val studio2 = "group tac" - val studio3 = "hitsuji no uta production committee" - val studio4 = "inter communications inc." - val studio5 = "ken groove" - val studio6 = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val studio1 = "kitty media" - val studio2 = "media blasters" - val studio3 = "on-lead" - val studio4 = "venet" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val studio1 = "009 re:cyborg production committee" - val studio2 = "amazonlaterna co,ltd." - val studio3 = "funimation" - val studio4 = "ishimori production" - val studio5 = "nippon television network corporation" - val studio6 = "production i.g" - val studio7 = "sanzigen" - val studio8 = "t-joy" - val studio9 = "vap" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val studio1 = "animation do" - val studio2 = "chu-2byo production partners" - val studio3 = "kyoto animation" - val studio4 = "lantis" - val studio5 = "pony canyon" - val studio6 = "rakuonsha" - val studio7 = "tbs" - val studio8 = "tokyo broadcasting system" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val studio1 = "chiba television" - val studio2 = "geneon universal entertainment" - val studio3 = "kbs kyoto" - val studio4 = "medil" - val studio5 = "mie television broadcasting" - val studio6 = "sun tv" - val studio7 = "television kanagawa" - val studio8 = "television saitama co., ltd." - val studio9 = "thefool" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val studio1 = "group tac" - val studio2 = "nippon herald film group" - val studio3 = "the asahi shimbun company" - val studio4 = "tv asahi" - - // when - val result = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - studio1, - studio2, - studio3, - studio4, - ), - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _studios = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _studios = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) - fun `doesn't fix studios if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _studios = hashSetOf( - value, - ), - activateChecks = false, - ) - - // then - assertThat(obj.studios).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddStudiosTests { - - @Test - fun `studios added are set to lower case`() { - // given - val studio = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio, - )) - - // then - assertThat(anime.studios).containsExactly( - studio.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from studios`() { - // given - val expectedStudio = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - " $expectedStudio", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `remove tailing whitespace from studios`() { - // given - val expectedStudios = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "$expectedStudios ", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudios, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in studios`() { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "bibury animation studios", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "$input $input", - )) - - // then - assertThat(anime.studios).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "bibury${input}animation${input}studios", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - "bi${input}bury ani${input}mation stu${input}dios", - )) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val studio = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf()) - - // then - assertThat(anime.studios).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val studio1 = "a studio" - val studio2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val studio1 = "satelight" - val studio2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val studio1 = "gallop" - val studio2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio2, - studio1, - studio3, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "tokyo HQ gallop" - val studio4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val studio1 = "anime beans" - val studio2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val studio1 = "studio durian" - val studio2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val studio1 = "sotsu" - val studio2 = "square enix" - val studio3 = "tv aichi" - val studio4 = "tv osaka" - val studio5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val studio1 = "tokyo kids" - val studio2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val studio1 = "groove corporation" - val studio2 = "group tac" - val studio3 = "hitsuji no uta production committee" - val studio4 = "inter communications inc." - val studio5 = "kenmedia" - val studio6 = "ken groove" - val studio7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val studio1 = "kitty media" - val studio2 = "media blasters" - val studio3 = "on-lead" - val studio4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val studio1 = "009 re:cyborg production committee" - val studio2 = "amazonlaterna co,ltd." - val studio3 = "funimation" - val studio4 = "ishimori production" - val studio5 = "nippon television network corporation" - val studio6 = "production i.g" - val studio7 = "sanzigen" - val studio8 = "t-joy" - val studio9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val studio1 = "animation do" - val studio2 = "chu-2byo production partners" - val studio3 = "kyoto animation" - val studio4 = "lantis" - val studio5 = "pony canyon" - val studio6 = "rakuonsha" - val studio7 = "tbs" - val studio8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val studio1 = "chiba television" - val studio2 = "geneon universal entertainment" - val studio3 = "kbs kyoto" - val studio4 = "medil" - val studio5 = "mie television broadcasting" - val studio6 = "sun tv" - val studio7 = "television kanagawa" - val studio8 = "television saitama co., ltd." - val studio9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val studio1 = "group tac" - val studio2 = "nippon herald film group" - val studio3 = "the asahi shimbun company" - val studio4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(listOf( - studio1, - studio2, - studio3, - studio4, - )) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios(listOf( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddStudiosVarargTests { - - @Test - fun `studios added are set to lower case`() { - // given - val studio = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addStudios(studio) - - // then - assertThat(anime.studios).containsExactly( - studio.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from studios`() { - // given - val expectedStudio = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - " $expectedStudio", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `remove tailing whitespace from studios`() { - // given - val expectedStudio = "example" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "$expectedStudio ", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in studios`() { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "bibury animation studios", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "$input $input", - ) - - // then - assertThat(anime.studios).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "bibury${input}animation${input}studios", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedStudio = "bibury animation studios" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - "bi${input}bury ani${input}mation stu${input}dios", - ) - - // then - assertThat(anime.studios).containsExactly( - expectedStudio, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val studio = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addStudios() - - // then - assertThat(anime.studios).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val studio1 = "a studio" - val studio2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio2, - studio1, - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val studio1 = "satelight" - val studio2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val studio1 = "gallop" - val studio2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio3, - studio2, - studio1, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val studio1 = "gallop co., ltd." - val studio2 = "studio gallop" - val studio3 = "tokyo HQ gallop" - val studio4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val studio1 = "anime beans" - val studio2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val studio1 = "studio durian" - val studio2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val studio1 = "sotsu" - val studio2 = "square enix" - val studio3 = "tv aichi" - val studio4 = "tv osaka" - val studio5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val studio1 = "tokyo kids" - val studio2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val studio1 = "groove corporation" - val studio2 = "group tac" - val studio3 = "hitsuji no uta production committee" - val studio4 = "inter communications inc." - val studio5 = "kenmedia" - val studio6 = "ken groove" - val studio7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val studio1 = "kitty media" - val studio2 = "media blasters" - val studio3 = "on-lead" - val studio4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val studio1 = "009 re:cyborg production committee" - val studio2 = "amazonlaterna co,ltd." - val studio3 = "funimation" - val studio4 = "ishimori production" - val studio5 = "nippon television network corporation" - val studio6 = "production i.g" - val studio7 = "sanzigen" - val studio8 = "t-joy" - val studio9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val studio1 = "animation do" - val studio2 = "chu-2byo production partners" - val studio3 = "kyoto animation" - val studio4 = "lantis" - val studio5 = "pony canyon" - val studio6 = "rakuonsha" - val studio7 = "tbs" - val studio8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val studio1 = "chiba television" - val studio2 = "geneon universal entertainment" - val studio3 = "kbs kyoto" - val studio4 = "medil" - val studio5 = "mie television broadcasting" - val studio6 = "sun tv" - val studio7 = "television kanagawa" - val studio8 = "television saitama co., ltd." - val studio9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - studio5, - studio6, - studio7, - studio8, - studio9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val studio1 = "group tac" - val studio2 = "nippon herald film group" - val studio3 = "the asahi shimbun company" - val studio4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addStudios( - studio1, - studio2, - studio3, - studio4, - ) - - // then - assertThat(anime.studios).containsExactlyInAnyOrder( - studio1, - studio2, - studio3, - studio4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addStudios( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - } - - @Nested - inner class ProducersTest { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - // when - anime.producers.clear() - - // then - assertThat(anime.producers).isNotEmpty() - } - - @Nested - inner class AddProducersConstructorTests { - - @Test - fun `producers added by constructor are set to lower case`() { - // given - val producer = "EXAMPLE" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer, - ), - ) - - // then - assertThat(result.producers).containsExactly( - producer.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from producers`() { - // given - val expectedProducer = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - " $expectedProducer", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `remove tailing whitespace from producers`() { - // given - val expectedProducer = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "$expectedProducer ", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in producers`() { - // given - val expectedProducer = "pony canyon" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "pony canyon", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add producer if it's a blank string`(input: String) { - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.producers).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedProducer = "pony canyon" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "pony${input}canyon", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedProducer = "pony canyon" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "po${input}ny can${input}yon", - ), - ) - - // then - assertThat(result.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val producer = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf(), - ) - - // then - assertThat(result.producers).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val producer1 = "a producer" - val producer2 = "before the other" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer1, - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val producer1 = "satelight" - val producer2 = "satelight inc." - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer1, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val producer1 = "gallop" - val producer2 = "studio gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer1, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "gallop" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer2, - producer3, - producer1, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "tokyo HQ gallop" - val producer4 = "gallop made up" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val producer1 = "anime beans" - val producer2 = "tate anime" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val producer1 = "studio durian" - val producer2 = "wit studio" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val producer1 = "sotsu" - val producer2 = "square enix" - val producer3 = "tv aichi" - val producer4 = "tv osaka" - val producer5 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val producer1 = "tokyo kids" - val producer2 = "tv tokyo" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val producer1 = "groove corporation" - val producer2 = "group tac" - val producer3 = "hitsuji no uta production committee" - val producer4 = "inter communications inc." - val producer5 = "kenmedia" - val producer6 = "ken groove" - val producer7 = "madhouse" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val producer1 = "kitty media" - val producer2 = "media blasters" - val producer3 = "on-lead" - val producer4 = "venet" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val producer1 = "009 re:cyborg production committee" - val producer2 = "amazonlaterna co,ltd." - val producer3 = "funimation" - val producer4 = "ishimori production" - val producer5 = "nippon television network corporation" - val producer6 = "production i.g" - val producer7 = "sanzigen" - val producer8 = "t-joy" - val producer9 = "vap" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val producer1 = "animation do" - val producer2 = "chu-2byo production partners" - val producer3 = "kyoto animation" - val producer4 = "lantis" - val producer5 = "pony canyon" - val producer6 = "rakuonsha" - val producer7 = "tbs" - val producer8 = "tokyo broadcasting system" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val producer1 = "chiba television" - val producer2 = "geneon universal entertainment" - val producer3 = "kbs kyoto" - val producer4 = "medil" - val producer5 = "mie television broadcasting" - val producer6 = "sun tv" - val producer7 = "television kanagawa" - val producer8 = "television saitama co., ltd." - val producer9 = "thefool" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val producer1 = "group tac" - val producer2 = "nippon herald film group" - val producer3 = "the asahi shimbun company" - val producer4 = "tv asahi" - - // when - val result = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - producer1, - producer2, - producer3, - producer4, - ), - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _producers = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _producers = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) - fun `doesn't fix producers if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _producers = hashSetOf( - value, - ), - activateChecks = false, - ) - - // then - assertThat(obj.producers).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddProducersTests { - - @Test - fun `producers added are set to lower case`() { - // given - val producer = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer, - )) - - // then - assertThat(anime.producers).containsExactly( - producer.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - " $expectedProducer", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `remove tailing whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "$expectedProducer ", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in producers`() { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "pony canyon", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add studios if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "$input $input", - )) - - // then - assertThat(anime.producers).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "pony${input}canyon", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - "po${input}ny can${input}yon", - )) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val producer = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf()) - - // then - assertThat(anime.producers).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val producer1 = "a producer" - val producer2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer2, - producer1, - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val producer1 = "satelight" - val producer2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val producer1 = "gallop" - val producer2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer2, - producer3, - producer1, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "tokyo HQ gallop" - val producer4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val producer1 = "anime beans" - val producer2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val producer1 = "studio durian" - val producer2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val producer1 = "sotsu" - val producer2 = "square enix" - val producer3 = "tv aichi" - val producer4 = "tv osaka" - val producer5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val producer1 = "tokyo kids" - val producer2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val producer1 = "groove corporation" - val producer2 = "group tac" - val producer3 = "hitsuji no uta production committee" - val producer4 = "inter communications inc." - val producer5 = "kenmedia" - val producer6 = "ken groove" - val producer7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val producer1 = "kitty media" - val producer2 = "media blasters" - val producer3 = "on-lead" - val producer4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val producer1 = "009 re:cyborg production committee" - val producer2 = "amazonlaterna co,ltd." - val producer3 = "funimation" - val producer4 = "ishimori production" - val producer5 = "nippon television network corporation" - val producer6 = "production i.g" - val producer7 = "sanzigen" - val producer8 = "t-joy" - val producer9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val producer1 = "animation do" - val producer2 = "chu-2byo production partners" - val producer3 = "kyoto animation" - val producer4 = "lantis" - val producer5 = "pony canyon" - val producer6 = "rakuonsha" - val producer7 = "tbs" - val producer8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val producer1 = "chiba television" - val producer2 = "geneon universal entertainment" - val producer3 = "kbs kyoto" - val producer4 = "medil" - val producer5 = "mie television broadcasting" - val producer6 = "sun tv" - val producer7 = "television kanagawa" - val producer8 = "television saitama co., ltd." - val producer9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val producer1 = "group tac" - val producer2 = "nippon herald film group" - val producer3 = "the asahi shimbun company" - val producer4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(listOf( - producer1, - producer2, - producer3, - producer4, - )) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddProducersVarargTests { - - @Test - fun `producers added are set to lower case`() { - // given - val producer = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addProducers(producer) - - // then - assertThat(anime.producers).containsExactly( - producer.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - " $expectedProducer", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `remove tailing whitespace from producers`() { - // given - val expectedProducer = "example" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "$expectedProducer ", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in producers`() { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "pony canyon", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `don't add producers if it's a blank string`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "$input $input", - ) - - // then - assertThat(anime.producers).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "pony${input}canyon", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedProducer = "pony canyon" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - "po${input}ny can${input}yon", - ) - - // then - assertThat(anime.producers).containsExactly( - expectedProducer, - ) - } - - @Test - fun `correctly adds a single value`() { - // given - val producer = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer, - ) - } - - @Test - fun `does nothing in case of an empty list`() { - // given - val anime = AnimeRaw("Test") - - // when - anime.addProducers() - - // then - assertThat(anime.producers).isEmpty() - } - - @Test - fun `prevent duplicates`() { - // given - val producer1 = "a producer" - val producer2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer2, - producer1, - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { - // given - val producer1 = "satelight" - val producer2 = "satelight inc." - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { - // given - val producer1 = "gallop" - val producer2 = "studio gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer2, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "gallop" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { - // given - val producer1 = "gallop co., ltd." - val producer2 = "studio gallop" - val producer3 = "tokyo HQ gallop" - val producer4 = "gallop made up" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIME`() { - // given - val producer1 = "anime beans" - val producer2 = "tate anime" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { - // given - val producer1 = "studio durian" - val producer2 = "wit studio" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TV`() { - // given - val producer1 = "sotsu" - val producer2 = "square enix" - val producer3 = "tv aichi" - val producer4 = "tv osaka" - val producer5 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { - // given - val producer1 = "tokyo kids" - val producer2 = "tv tokyo" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { - // given - val producer1 = "groove corporation" - val producer2 = "group tac" - val producer3 = "hitsuji no uta production committee" - val producer4 = "inter communications inc." - val producer5 = "kenmedia" - val producer6 = "ken groove" - val producer7 = "madhouse" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { - // given - val producer1 = "kitty media" - val producer2 = "media blasters" - val producer3 = "on-lead" - val producer4 = "venet" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { - // given - val producer1 = "009 re:cyborg production committee" - val producer2 = "amazonlaterna co,ltd." - val producer3 = "funimation" - val producer4 = "ishimori production" - val producer5 = "nippon television network corporation" - val producer6 = "production i.g" - val producer7 = "sanzigen" - val producer8 = "t-joy" - val producer9 = "vap" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { - // given - val producer1 = "animation do" - val producer2 = "chu-2byo production partners" - val producer3 = "kyoto animation" - val producer4 = "lantis" - val producer5 = "pony canyon" - val producer6 = "rakuonsha" - val producer7 = "tbs" - val producer8 = "tokyo broadcasting system" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { - // given - val producer1 = "chiba television" - val producer2 = "geneon universal entertainment" - val producer3 = "kbs kyoto" - val producer4 = "medil" - val producer5 = "mie television broadcasting" - val producer6 = "sun tv" - val producer7 = "television kanagawa" - val producer8 = "television saitama co., ltd." - val producer9 = "thefool" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - producer5, - producer6, - producer7, - producer8, - producer9, - ) - } - - @Test - fun `prevent false positive for identifying overlapping duplicates - GROUP`() { - // given - val producer1 = "group tac" - val producer2 = "nippon herald film group" - val producer3 = "the asahi shimbun company" - val producer4 = "tv asahi" - val anime = AnimeRaw("Test") - - // when - anime.addProducers( - producer1, - producer2, - producer3, - producer4, - ) - - // then - assertThat(anime.producers).containsExactlyInAnyOrder( - producer1, - producer2, - producer3, - producer4, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addProducers( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _sources = hashSetOf( - URI("https://example.org/anime/1535"), - URI("https://myanimelist.net/anime/1535"), - ), - ) - - // when - anime.sources.clear() - - // then - assertThat(anime.sources).isNotEmpty() - } - - @Nested - inner class AddSourcesConstructorTests { - - @Test - fun `add source`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // then - assertThat(anime.sources).containsExactly(source) - } - - @Test - fun `cannot add duplicated source link`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - source, - ), - ) - - // then - assertThat(result.sources).containsExactly(source) - } - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - _relatedAnime = hashSetOf( - RelatedAnime(source), - ), - ) - - // then - assertThat(result.sources).containsExactly( - source, - ) - assertThat(result.relatedAnime).isEmpty() - } - - @Test - fun `don't remove related anime if activateChecks is false`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - _relatedAnime = hashSetOf( - RelatedAnime(source), - ), - activateChecks = false, - ) - - // then - assertThat(result.sources).containsExactly( - source, - ) - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(source), - ) - } - } - - @Nested - inner class AddSourcesTests { - - @Test - fun `add source`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addSources(listOf( - source, - )) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `cannot add duplicated source link`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.addSources(listOf( - source, - )) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(source), - ), - ) - - // when - anime.addSources(listOf( - source, - )) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class AddSourcesVarargTests { - - @Test - fun `add source`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addSources( - source, - ) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `cannot add duplicated source link`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.addSources(source) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(source), - ), - ) - - // when - anime.addSources(source) - - // then - assertThat(anime.sources).containsExactly( - source, - ) - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class RemoveSourceIfTests { - - @Test - fun `successfully remove source`() { - // given - val source = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.removeSourceIf { it.toString() == "https://myanimelist.net/anime/2994" } - - // then - assertThat(anime.sources).isEmpty() - } - - @Test - fun `don't remove anything if the condition doesn't match`() { - // given - val source = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - ) - - // when - anime.removeSourceIf { it.toString().contains("anidb.net") } - - // then - assertThat(anime.sources).containsExactly( - source, - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Nested - inner class AddRelatedAnimeConstructorTests { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://example.org/anime/1535")), - RelatedAnime(URI("https://myanimelist.net/anime/1535")), - ), - ) - - // when - anime.relatedAnime.clear() - - // then - assertThat(anime.relatedAnime).isNotEmpty() - } - - @Test - fun `add related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - - // when - val result = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(relatedAnime), - ), - ) - - // then - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(relatedAnime), - ) - } - - @Test - fun `cannot add duplicated link for related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - - // when - val result = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(relatedAnime), - RelatedAnime(relatedAnime), - ), - ) - - // then - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(relatedAnime), - ) - } - - @Test - fun `cannot add a related anime if the links is already part of the sources`() { - // given - val link = URI("https://myanimelist.net/anime/1535") - - // when - val result = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - link, - ), - _relatedAnime = hashSetOf( - RelatedAnime(link), - ), - ) - - // then - assertThat(result.relatedAnime).isEmpty() - } - - @Test - fun `doesn't remove sources from relatedAnimeif activateChecks is false`() { - // when - val obj = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/6351"), - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2167")), - RelatedAnime(URI("https://myanimelist.net/anime/6351")), - ), - activateChecks = false, - ) - - // then - assertThat(obj.relatedAnime).hasSize(2) - } - } - - @Nested - inner class AddRelatedAnimeTests { - - @Test - fun `add related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addRelatedAnime(listOf( - relatedAnime, - )) - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(relatedAnime), - ) - } - - @Test - fun `cannot add duplicated link for related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(relatedAnime), - ), - ) - - // when - anime.addRelatedAnime(listOf( - relatedAnime, - )) - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(relatedAnime), - ) - } - - @Test - fun `cannot add a related anime if the links is already part of the sources`() { - // given - val link = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - link, - ), - ) - - // when - anime.addRelatedAnime(listOf( - link, - )) - - // then - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class AddRelatedAnimeVarargTests { - - @Test - fun `add related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addRelatedAnime( - relatedAnime, - ) - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(relatedAnime), - ) - } - - @Test - fun `cannot add duplicated link for related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(relatedAnime), - ), - ) - - // when - anime.addRelatedAnime( - relatedAnime, - ) - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(relatedAnime), - ) - } - - @Test - fun `cannot add a related anime if the links is already part of the sources`() { - // given - val link = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - link, - ), - ) - - // when - anime.addRelatedAnime(link) - - // then - assertThat(anime.relatedAnime).isEmpty() - } - } - - @Nested - inner class AddRelatedAnimeWithRelationTests { - - @Test - fun `add related anime including the kind of relation`() { - // given - val relatedAnime = RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary") - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - anime.addRelatedAnime(relatedAnime) - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), - ) - } - - @Test - fun `an entry having a relation replaces an entry for the same link without relation`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - ) - - // when - anime.addRelatedAnime(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")) - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), - ) - } - - @Test - fun `an entry without relation doesn't replace an entry for the same link having a relation`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), - ), - ) - - // when - anime.addRelatedAnime(URI("https://myanimelist.net/anime/2994")) - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), - ) - } - - @Test - fun `keeps only one entry per link`() { - // when - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), - RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Other"), - ), - ) - - // then - assertThat(anime.relatedAnime).hasSize(1) - } - - @Test - fun `cannot add a related anime having a relation if the link is already part of the sources`() { - // given - val link = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - link, - ), - ) - - // when - anime.addRelatedAnime(RelatedAnime(link, "Sequel")) - - // then - assertThat(anime.relatedAnime).isEmpty() - } - - @Test - fun `merging keeps the kind of relation`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://myanimelist.net/anime/1535")), - _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")), - ) - val other = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://anilist.co/anime/1535")), - _relatedAnime = hashSetOf(RelatedAnime(URI("https://anilist.co/anime/2994"), "SUMMARY")), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), - RelatedAnime(URI("https://anilist.co/anime/2994"), "SUMMARY"), - ) - } - } - - @Nested - inner class RemoveRelationIfTests { - - @Test - fun `successfully remove related anime`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(relatedAnime), - ), - ) - - // when - anime.removeRelatedAnimeIf { it.toString() == "https://myanimelist.net/anime/2994" } - - // then - assertThat(anime.relatedAnime).isEmpty() - } - - @Test - fun `don't remove anything if condition doesn't match`() { - // given - val relatedAnime = URI("https://myanimelist.net/anime/2994") - val anime = AnimeRaw( - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(relatedAnime), - ), - ) - - // when - anime.removeRelatedAnimeIf { it.toString().contains("anidb.net") } - - // then - assertThat(anime.relatedAnime).containsExactly( - RelatedAnime(relatedAnime), - ) - } - } - } - - @Nested - inner class EqualityTests { - - @Test - fun `is equal if titles are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - ) - - val b = AnimeRaw( - _title = title, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if titles are different`() { - // given - val a = AnimeRaw( - _title = "Death Note", - ) - - val b = AnimeRaw( - _title = "デスノート", - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if source links are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - ) - - val b = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if source links are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - ) - - val b = AnimeRaw( - _title = title, - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - URI("https://anidb.net/anime/4563"), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if synonyms are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - val b = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if synonyms are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - val b = AnimeRaw( - _title = title, - _synonyms = hashSetOf( - "Caderno da Morte", - "Quaderno della Morte", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if types are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - type = TV, - ) - - val b = AnimeRaw( - _title = title, - type = TV, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if types are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - type = TV, - ) - - val b = AnimeRaw( - _title = title, - type = MOVIE, - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if episodes are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - episodes = 37, - ) - - val b = AnimeRaw( - _title = title, - episodes = 37, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if episodes are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - episodes = 37, - ) - - val b = AnimeRaw( - _title = title, - episodes = 1, - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if status is the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - status = FINISHED, - ) - - val b = AnimeRaw( - _title = title, - status = FINISHED, - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if status is different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - status = FINISHED, - ) - - val b = AnimeRaw( - _title = title, - status = UNKNOWN_STATUS, - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if animeSeasons are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = FALL, - year = 2006, - ), - ) - - val b = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = FALL, - year = 2006, - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if animeSeasons are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = UNDEFINED, - year = 2006, - ), - ) - - val b = AnimeRaw( - _title = title, - animeSeason = AnimeSeason( - season = FALL, - year = 2006, - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if pictures are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - picture = URI("https://example.org/pic/1.png"), - ) - - val b = AnimeRaw( - _title = title, - picture = URI("https://example.org/pic/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if pictures are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - picture = URI("https://example.org/pictures/1.png"), - ) - - val b = AnimeRaw( - _title = title, - picture = URI("https://example.org/pic/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if thumbnail are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnail/1.png"), - ) - - val b = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnail/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if thumbnail are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnails/1.png"), - ) - - val b = AnimeRaw( - _title = title, - thumbnail = URI("https://example.org/thumbnail/1.png"), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if durations are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - duration = Duration(20, MINUTES), - ) - - val b = AnimeRaw( - _title = title, - duration = Duration(20, MINUTES), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if durations are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - duration = Duration(21, MINUTES), - ) - - val b = AnimeRaw( - _title = title, - duration = Duration(20, MINUTES), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if scores are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - val b = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if scores are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - val b = AnimeRaw( - _title = title, - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - MetaDataProviderScoreValue( - hostname = "other.com", - value = 9.50, - range = 1.0..10.0, - ) - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if related anime are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - ) - - val b = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if related anime are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - ) - - val b = AnimeRaw( - _title = title, - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - RelatedAnime(URI("http://anilist.co/anime/2994")), - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if tags are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _tags = hashSetOf( - "comedy", - "slice of life", - ), - ) - - val b = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - "comedy", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if tags are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - ), - ) - - val b = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - "comedy", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if studios are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if studios are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _studios = hashSetOf( - "studio 1", - "studio 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is equal if producers are the same`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isTrue() - assertThat(a.hashCode()).isEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if producers are different`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 2", - ), - ) - - val b = AnimeRaw( - _title = title, - _producers = hashSetOf( - "producer 1", - "producer 2", - ), - ) - - // when - val result = a == b - - // then - assertThat(result).isFalse() - assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) - } - - @Test - fun `is not equal if the other object is of a different type`() { - // given - val title = "Death Note" - val a = AnimeRaw( - _title = title, - _tags = hashSetOf( - "slice of life", - ), - ) - - // when - val result = a.equals(1) - - // then - assertThat(result).isFalse() - } - } - - @Nested - inner class MergeTests { - - @Test - fun `use this number of episodes` () { - // given - val anime = AnimeRaw( - _title = "this", - episodes = 12, - ) - val other = AnimeRaw( - _title = "other", - episodes = 13, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.episodes).isEqualTo(12) - } - - @Test - fun `use other's number of episodes if this number of episodes is 0` () { - // given - val anime = AnimeRaw( - _title = "this", - episodes = 0, - ) - val other = AnimeRaw( - _title = "other", - episodes = 13, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.episodes).isEqualTo(13) - } - - @Test - fun `use this type` () { - // given - val anime = AnimeRaw( - _title = "this", - type = MOVIE, - ) - val other = AnimeRaw( - _title = "other", - type = SPECIAL, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - - @Test - fun `use other's type if this type is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - type = UNKNOWN_TYPE, - ) - val other = AnimeRaw( - _title = "other", - type = SPECIAL, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - - @Test - fun `use this status` () { - // given - val anime = AnimeRaw( - _title = "this", - status = FINISHED, - ) - val other = AnimeRaw( - _title = "other", - status = ONGOING, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - - @Test - fun `use other's status if this status is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - status = UNKNOWN_STATUS, - ) - val other = AnimeRaw( - _title = "other", - status = ONGOING, - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - - @Test - fun `use this duration` () { - // given - val anime = AnimeRaw( - _title = "this", - duration = Duration(120, MINUTES), - ) - val other = AnimeRaw( - _title = "other", - duration = Duration(125, MINUTES), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.duration).isEqualTo(Duration(120, MINUTES)) - } - - @Test - fun `use other's duration if this duration is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - duration = UNKNOWN_DURATION, - ) - val other = AnimeRaw( - _title = "other", - duration = Duration(125, MINUTES), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.duration).isEqualTo(Duration(125, MINUTES)) - } - - @Test - fun `use this season` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = FALL, - year = 2010, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - assertThat(result.animeSeason.year).isEqualTo(2010) - } - - @Test - fun `use other's season if this season is UNDEFINED` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = UNDEFINED, - year = 2010, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - assertThat(result.animeSeason.year).isEqualTo(2010) - } - - @Test - fun `use this year` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = FALL, - year = 2010, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - assertThat(result.animeSeason.year).isEqualTo(2010) - } - - @Test - fun `use other's year if this year is UNKNOWN` () { - // given - val anime = AnimeRaw( - _title = "this", - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - ) - val other = AnimeRaw( - _title = "other", - animeSeason = AnimeSeason( - season = WINTER, - year = 2011, - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - assertThat(result.animeSeason.year).isEqualTo(2011) - } - - @Test - fun `add title and synonyms of the other anime to this anime's synonyms`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - val other = AnimeRaw( - _title = "DEATH NOTE", - _synonyms = hashSetOf( - "Caderno da Morte", - "Quaderno della Morte", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.title).isEqualTo(anime.title) - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Caderno da Morte", - "DEATH NOTE", - "Quaderno della Morte", - ) - } - - @Test - fun `merge related anime and source links`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/8146")), - RelatedAnime(URI("https://anidb.net/anime/8147")), - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.sources).containsExactlyInAnyOrder( - URI("https://anidb.net/anime/4563"), - URI("https://myanimelist.net/anime/1535"), - ) - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://anidb.net/anime/8146")), - RelatedAnime(URI("https://anidb.net/anime/8147")), - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ) - } - - @Test - fun `merge tags`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _tags = hashSetOf( - "Psychological", - "Thriller", - "Shounen", - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _tags = hashSetOf( - "Mystery", - "Police", - "Psychological", - "Supernatural", - "Thriller", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "mystery", - "police", - "psychological", - "shounen", - "supernatural", - "thriller", - ) - } - - @Test - fun `merge studios`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _studios = hashSetOf( - "studio 1" - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _studios = hashSetOf( - "studio 2", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "studio 1", - "studio 2", - ) - } - - @Test - fun `merge producers`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - _producers = hashSetOf( - "producer 1" - ), - ) - - val other = AnimeRaw( - _title = "Death Note", - _producers = hashSetOf( - "producer 2", - ), - ) - - // when - val result = anime.mergeWith(other) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "producer 1", - "producer 2", - ) - } - - @Test - fun `merge scores`() { - // given - val title = "Death Note" - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val a = AnimeRaw( - _title = title, - ).addScores( - scoreA, - ) - - val scoreB = MetaDataProviderScoreValue( - hostname = "other.com", - value = 9.5, - range = 1.0..10.0, - ) - val b = AnimeRaw( - _title = title, - ).addScores( - scoreB, - ) - - // when - val result = a.mergeWith(b) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreA, - scoreB, - ) - } - - @Test - fun `overrides score entries with same hostname`() { - // given - val title = "Death Note" - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 2.41, - range = 1.0..5.0, - ) - val a = AnimeRaw( - _title = title, - ).addScores( - scoreA, - ) - - val scoreB = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val b = AnimeRaw( - _title = title, - ).addScores( - scoreB, - ) - - // when - val result = a.mergeWith(b) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreB, - ) - } - } - - @Nested - inner class TagsTest { - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - _tags = hashSetOf( - "thriller", - "acion", - ), - ) - - // when - anime.tags.clear() - - // then - assertThat(anime.tags).isNotEmpty() - } - - @Nested - inner class AddTagsConstructorTests { - - @Test - fun `tags added by constructor are set to lower case`() { - // given - val tag = "EXAMPLE" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - tag, - ), - ) - - // then - assertThat(result.tags).containsExactly( - tag.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from tags`() { - // given - val expectedTag = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - " $expectedTag", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `remove tailing whitespace from tags`() { - // given - val expectedTag = "example" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "$expectedTag ", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in tags`() { - // given - val expectedTag = "slice of life" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "slice of life", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank tag`(input: String) { - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "$input $input", - ), - ) - - // then - assertThat(result.tags).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTag = "slice of life" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "slice${input}of${input}life", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTag = "slice of life" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "sli${input}ce of li${input}fe", - ), - ) - - // then - assertThat(result.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `prevent duplicates`() { - // given - val tag1 = "a tag" - val tag2 = "before the other" - - // when - val result = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - tag2, - tag1, - tag1, - tag2, - ), - ) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - tag1, - tag2, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _tags = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // when - val anime = AnimeRaw( - _title = "Main title", - _tags = hashSetOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ), - ) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) - fun `doesn't fix tags if activateChecks is false`(value: String) { - // when - val obj = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - value, - ), - activateChecks = false, - ) - - // then - assertThat(obj.tags).containsExactlyInAnyOrder( - value, - ) - } - } - - @Nested - inner class AddTagsTests { - - @Test - fun `tags added are set to lower case`() { - // given - val tag = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - tag, - )) - - // then - assertThat(anime.tags).containsExactly( - tag.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - " $expectedTag", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `remove tailing whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "$expectedTag ", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in tags`() { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "slice of life", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank tag`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "$input $input", - )) - - // then - assertThat(anime.tags).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "slice${input}of${input}life", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - "sli${input}ce o${input}f li${input}fe", - )) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `prevent duplicates`() { - // given - val tag1 = "a tag" - val tag2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addTags(listOf( - tag2, - tag1, - tag1, - tag2, - )) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - tag1, - tag2, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags(listOf( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - )) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - - @Nested - inner class AddTagsVarargTests { - - @Test - fun `tags added are set to lower case`() { - // given - val tag = "EXAMPLE" - val anime = AnimeRaw("Test") - - // when - anime.addTags(tag) - - // then - assertThat(anime.tags).containsExactly( - tag.lowercase(), - ) - } - - @Test - fun `remove leading whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - " $expectedTag", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `remove tailing whitespace from tags`() { - // given - val expectedTag = "example" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "$expectedTag ", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `replace multiple whitespaces with a single whitespace in tags`() { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "slice of life", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `must not add blank tag`(input: String) { - // given - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "$input $input", - ) - - // then - assertThat(anime.tags).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - ]) - fun `normalize visible whitespaces to a normale whitespace`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "slice${input}of${input}life", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - ]) - fun `remove non-visible whitespaces`(input: String) { - // given - val expectedTag = "slice of life" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - "sli${input}ce o${input}f li${input}fe", - ) - - // then - assertThat(anime.tags).containsExactly( - expectedTag, - ) - } - - @Test - fun `prevent duplicates`() { - // given - val tag1 = "a tag" - val tag2 = "before the other" - val anime = AnimeRaw("Test") - - // when - anime.addTags( - tag2, - tag1, - tag1, - tag2, - ) - - // then - assertThat(anime.tags).containsExactlyInAnyOrder( - tag1, - tag2, - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u2019", - "\u02BB", - "\u02BC", - "\u2018", - "\u275B", - "\u275C", - "\u02B9", - "\u02BE", - "\u02C8", - "\u055A", - "\u07F4", - "\u07F5", - "\u1FBF", - "\u2032", - "\uA78C", - "\uFF07", - "\u0060", - ]) - fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she\u0027s, john\u0027s, it\u0027s", - "the girls\u0027, the dogs\u0027", - "don\u0027t, can\u0027t", - "i\u0027m", - "i\u0027d, they\u0027d", - "rock \u0027n\u0027 roll", - "he\u0027ll, we\u0027ll", - "your\u0027re", - "i\u0027ve", - "\u0027ere", - "\u0027cause", - "\u0027bout", - "\u0027fore", - ) - } - - @ParameterizedTest - @ValueSource(strings = [ - "\u0027", - ]) - fun `doesn't normalize acceptable apostrophes`(value: String) { - // given - val anime = AnimeRaw( - _title = "Main title", - ) - - // when - val result = anime.addTags( - "she${value}s, John${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "I${value}m", - "I${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "I${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "she${value}s, john${value}s, it${value}s", - "the girls$value, the dogs${value}", - "don${value}t, can${value}t", - "i${value}m", - "i${value}d, they${value}d", - "rock ${value}n$value roll", - "he${value}ll, we${value}ll", - "your${value}re", - "i${value}ve", - "${value}ere", - "${value}cause", - "${value}bout", - "${value}fore", - ) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `default duration is 0 seconds`() { - // given - val expectedDuration = Duration(0, SECONDS) - - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.duration).isEqualTo(expectedDuration) - } - - @Test - fun `setting a duration of 10 seconds`() { - // when - val result = Duration(10, SECONDS) - - // then - assertThat(result.duration).isEqualTo(10) - assertThat(result.toString()).isEqualTo("10 seconds") - } - - @Test - fun `duration of a minute is equal to a duration of 60 seconds`() { - // given - val durationInSeconds = Duration(60, SECONDS) - - // when - val result = Duration(1, MINUTES) - - // then - assertThat(result).isEqualTo(durationInSeconds) - assertThat(result.duration).isEqualTo(60) - assertThat(result.toString()).isEqualTo("60 seconds") - } - - @Test - fun `duration of an hour is equal to a duration of 60 minutes`() { - // given - val durationInMinutes = Duration(60, MINUTES) - - // when - val result = Duration(1, HOURS) - - // then - assertThat(result).isEqualTo(durationInMinutes) - assertThat(result.duration).isEqualTo(3600) - assertThat(result.toString()).isEqualTo("3600 seconds") - } - - @Test - fun `returns false on equals check if other object is not of the same type`() { - // given - val duration = Duration(60, MINUTES) - - // when - val result = duration.equals(EMPTY) - - // then - assertThat(result).isFalse() - } - } - - @Nested - inner class AnimeSeasonTests { - - @Test - fun `default year is 0 indicating unknown and season is undefined`() { - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.animeSeason.year).isZero() - assertThat(result.animeSeason.isYearOfPremiereUnknown()).isTrue() - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Nested - inner class SeasonTests { - - @Test - fun `default season if nothing has been set`() { - // when - val result = AnimeRaw("test") - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - assertThat(result.animeSeason.year).isZero() - } - } - - @Nested - inner class ToStringTests { - - @Test - fun `create formatted string listing all properties`() { - // when - val result = TestAnimeRawObjects.AllPropertiesSet.obj.toString() - - // then - assertThat(result).isEqualTo( - """ - AnimeRaw( - sources = [https://anilist.co/anime/177191] - title = Go-toubun no Hanayome * - type = SPECIAL - episodes = 2 - status = FINISHED - animeSeason = AnimeSeason(season=FALL, year=2024) - picture = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg - thumbnail = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg - duration = 1440 seconds - scores = [MetaDataProviderScoreValue(hostname=anilist.co, value=75.0, range=1.0..100.0)] - synonyms = [The Quintessential Quintuplets*, 五等分の花嫁*] - studios = [bibury animation studios] - producers = [dax production, nichion, pony canyon] - relatedAnime = [https://anilist.co/anime/131520] - tags = [comedy, drama, ensemble cast, female harem, heterosexual, language barrier, male protagonist, marriage, primarily female cast, romance, school, shounen, twins] - ) - """.trimIndent() - ) - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `default value is 0`() { - // when - val result = AnimeRaw("test") - - // then - assertThat(result.episodes).isZero() - } - - @Test - fun `throws exception if number of episodes is negative`() { - // when - val result = assertThrows<IllegalArgumentException> { - AnimeRaw( - _title = "test", - episodes = -1, - ) - } - - // then - assertThat(result).hasMessage("Episodes cannot have a negative value.") - } - - @Test - fun `doesn't throw an exception if number of episodes is negative and activateChecks is false`() { - // when - val result = AnimeRaw( - _title = "test", - episodes = -1, - activateChecks = false, - ) - - // then - assertThat(result.episodes).isEqualTo(-1) - } - } - - @Nested - inner class PerformChecksTests { - - @Nested - inner class TitleTests { - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) - fun `fixes title`(value: String) { - // given - val obj = AnimeRaw( - _title = value, - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "Death Note", - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - } - - @Nested - inner class EpisodeTests { - - @Test - fun `throws exception if number of episodes is negative`() { - val anime = AnimeRaw( - _title = "test", - episodes = -1, - activateChecks = false, - ) - - // when - val result = assertThrows<IllegalArgumentException> { - anime.performChecks() - } - - // then - assertThat(result).hasMessage("Episodes cannot have a negative value.") - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `removes sources from relatedAnime`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/6351"), - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2167")), - RelatedAnime(URI("https://myanimelist.net/anime/6351")), - ), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(""" - { - "sources": [ - "https://myanimelist.net/anime/6351" - ], - "title": "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED" - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2167" - } - ], - "tags": [] - } - """.trimIndent()) - } - } - - @Nested - inner class SynonymsTests { - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) - fun `fixes synonyms`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - "Death Note", - ), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - - @ParameterizedTest - @ValueSource(strings = ["", " ", " ", "\u200C"]) - fun `removes blank entries from synonyms`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _synonyms = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _synonyms = HashSet(), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - } - - @Nested - inner class TagsTests { - - @ParameterizedTest - @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE"]) - fun `fixes tags`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - "death note", - ), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - - @ParameterizedTest - @ValueSource(strings = ["", " ", " ", "\u200C"]) - fun `removes blank entries from tags`(value: String) { - // given - val obj = AnimeRaw( - _title = "デスノート", - _tags = hashSetOf( - value, - ), - activateChecks = false, - ) - - val expected = AnimeRaw( - _title = "デスノート", - _tags = HashSet(), - activateChecks = false, - ) - - // when - obj.performChecks() - - // then - assertThat(obj).isEqualTo(expected) - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `remove related anime if the same uri has been added to sources`() { - // given - val source = URI("https://myanimelist.net/anime/1535") - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - source, - ), - _relatedAnime = hashSetOf( - RelatedAnime(source), - ), - activateChecks = false, - ) - - // when - anime.performChecks() - - // then - assertThat(anime.sources).containsExactly(source) - assertThat(anime.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `default is empty`() { - // when - val result = AnimeRaw("Death Note") - - // then - assertThat(result.scores).isEmpty() - } - - @Test - fun `ensure that you cannot directly modify the internal hashset`() { - // given - val anime = AnimeRaw( - _title = "test", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ), - ) - - // when - anime.scores.clear() - - // then - assertThat(anime.scores).isNotEmpty() - } - - @Nested - inner class AddTagsTests { - - @Test - fun `ignores NoMetaDataProviderScore`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - val result = anime.addScores( - NoMetaDataProviderScore, - ) - - // then - assertThat(result.scores).isEmpty() - } - - @Test - fun `correctly adds score`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - score, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - - @Test - fun `correctly adds multiple scores`() { - // given - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val scoreB = MetaDataProviderScoreValue( - hostname = "other.com", - value = 6.83, - range = 1.0..10.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - scoreA, - scoreB, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreA, - scoreB, - ) - } - - @Test - fun `overrides existing score if the hostname is identical`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 2.54, - range = 1.0..5.0, - ), - ) - - // when - val result = anime.addScores( - score, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - } - - @Nested - inner class AddTagsVarargTests { - - @Test - fun `ignores NoMetaDataProviderScore`() { - // given - val anime = AnimeRaw( - _title = "Death Note", - ) - - // when - val result = anime.addScores( - listOf( - NoMetaDataProviderScore, - ) - ) - - // then - assertThat(result.scores).isEmpty() - } - - @Test - fun `correctly adds score`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - listOf( - score, - ) - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - - @Test - fun `correctly adds multiple scores`() { - // given - val scoreA = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val scoreB = MetaDataProviderScoreValue( - hostname = "other.com", - value = 6.83, - range = 1.0..10.0, - ) - val anime = AnimeRaw( - _title = "test", - ) - - // when - val result = anime.addScores( - listOf( - scoreA, - scoreB, - ) - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - scoreA, - scoreB, - ) - } - - @Test - fun `overrides existing score if the hostname is identical`() { - // given - val score = MetaDataProviderScoreValue( - hostname = "example.org", - value = 4.97, - range = 1.0..5.0, - ) - val anime = AnimeRaw( - _title = "test", - ).addScores( - listOf( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 2.54, - range = 1.0..5.0, - ), - ) - ) - - // when - val result = anime.addScores( - score, - ) - - // then - assertThat(result.scores).containsExactlyInAnyOrder( - score, - ) - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.anime + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.TestAnimeRawObjects +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeStatus.ONGOING +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.json.AnimeRawAdapter +import io.github.manamiproject.modb.test.exceptionExpected +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeRawTest { + + @Nested + inner class TitleTests { + + @Test + fun `remove leading whitespace from title`() { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw(" $expectedTitle") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @Test + fun `remove tailing whitespace from title`() { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw("$expectedTitle ") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in title`() { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTitle = "Death Note" + + // when + val result = AnimeRaw("Death${input}Note") + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // when + val result = AnimeRaw("Ba${input}ek") + + // then + assertThat(result.title).isEqualTo("Baek") + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `throws exception if title is blank`(value: String) { + // when + val result = assertThrows<IllegalArgumentException> { + AnimeRaw(value) + } + + // then + assertThat(result).hasMessage("Title cannot be blank.") + } + + @Test + fun `don't change title if activateChecks is set to false`() { + // given + val expectedTitle = " Death Note " + + // when + val result = AnimeRaw( + _title = expectedTitle, + activateChecks = false, + ) + + // then + assertThat(result.title).isEqualTo(expectedTitle) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `throws an exception if title is blank even if activateChecks is set to false`(value: String) { + // when + val result = exceptionExpected<IllegalArgumentException> { + AnimeRaw( + _title = value, + activateChecks = false, + ) + } + + // then + assertThat(result).hasMessage("Title cannot be blank.") + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _synonyms = hashSetOf( + "other title1", + "other title2", + ), + ) + + // when + anime.synonyms.clear() + + // then + assertThat(anime.synonyms).isNotEmpty() + } + + @Nested + inner class AddSynonymsConstructorTests { + + @Test + fun `must not add a synonym if it equals the title`() { + // given + val title = "Death Note" + + // when + val result = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + title, + ), + ) + + // then + assertThat(result.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank synonym`(input: String) { + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Caderno${input}da${input}Morte", + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Cader${input}no da Mor${input}te", + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @Test + fun `remove leading whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + + // when + val result = AnimeRaw( + _title = "Title", + _synonyms = hashSetOf( + " $expectedTitleOne", + " $expectedTitleTwo", + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove tailing whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + + // when + val result = AnimeRaw( + _title = "Title", + _synonyms = hashSetOf( + "$expectedTitleOne ", + "$expectedTitleTwo ", + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + + // when + val result = AnimeRaw( + _title = "Title", + _synonyms = hashSetOf( + "Death Note", + "Made in Abyss", + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `successfully add a synonym`() { + // given + val synonym = "Caderno da Morte" + + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + synonym, + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + synonym, + ) + } + + @Test + fun `must not add a duplicated synonym`() { + // given + val synonym = "Caderno da Morte" + + // when + val result = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + synonym, + synonym, + ), + ) + + // then + assertThat(result.synonyms).containsExactly( + synonym, + ) + } + + @Test + fun `synonym comparison to title is not case-sensitive`() { + // given + val title = "Death Note" + + // when + val result = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + title.uppercase(), + title.lowercase(), + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + title.uppercase(), + title.lowercase(), + ) + } + + @Test + fun `synonym comparison is not case-sensitive`() { + // given + val title = "Death Note" + + // when + val result = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + title, + title.lowercase(), + title.uppercase(), + ), + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + title, + title.uppercase(), + title.lowercase(), + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `correctly normalized usages of apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _synonyms = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + "she\u2019s, John\u2019s, it\u2019s", + "the girls\u2019, the dogs\u2019", + "don\u2019t, can\u2019t", + "I\u2019m", + "I\u2019d, they\u2019d", + "rock \u2019n\u2019 roll", + "he\u2019ll, we\u2019ll", + "your\u2019re", + "I\u2019ve", + "\u2019ere", + "\u2019cause", + "\u2019bout", + "\u2019fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _synonyms = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "", " ", " ", "\u200C"]) + fun `doesn't fix synonyms if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf(value), + activateChecks = false, + ) + + // then + assertThat(obj.synonyms).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddSynonymsTests { + + @Test + fun `must not add a synonym if it equals the title`() { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms(listOf( + "Death Note", + )) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank synonym`(input: String) { + // given + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addSynonyms(listOf( + "$input $input", + )) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Caderno da Morte" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "Death${input}Note", + "Caderno${input}da${input}Morte", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Caderno da Morte" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "Death Note", + "Cader${input}no da Mor${input}te", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove leading whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + " $expectedTitleOne", + " $expectedTitleTwo", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove tailing whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "$expectedTitleOne ", + "$expectedTitleTwo ", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms(listOf( + "Death Note", + "Made in Abyss", + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `successfully add a list of synonyms`() { + // given + val one = "Caderno da Morte" + val two = "DN" + val three = "Quaderno della Morte" + val four = "Sveska Smrti" + + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms(listOf( + four, + two, + three, + one, + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + three, + four, + ) + } + + @Test + fun `must not add a duplicated synonym`() { + // given + val one = "Caderno da Morte" + val two = "DN" + + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + two, + ), + ) + + // when + anime.addSynonyms(listOf( + two, + one, + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + ) + } + + @Test + fun `synonym comparison to title is not case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw(title) + + // when + anime.addSynonyms(listOf( + title.uppercase(), + title.lowercase(), + )) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title.uppercase(), + title.lowercase(), + ) + } + + @Test + fun `synonym comparison is not case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + title.lowercase(), + title.uppercase(), + ), + ) + + // when + anime.addSynonyms(title) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title, + title.uppercase(), + title.lowercase(), + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she\u2019s, John\u2019s, it\u2019s", + "the girls\u2019, the dogs\u2019", + "don\u2019t, can\u2019t", + "I\u2019m", + "I\u2019d, they\u2019d", + "rock \u2019n\u2019 roll", + "he\u2019ll, we\u2019ll", + "your\u2019re", + "I\u2019ve", + "\u2019ere", + "\u2019cause", + "\u2019bout", + "\u2019fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddSynonymsVarargTests { + + @Test + fun `must not add a synonym if it equals the title`() { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "Death Note", + ) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank synonym`(input: String) { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "$input $input", + ) + + // then + assertThat(anime.synonyms).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "Caderno${input}da${input}Morte", + ) + + // then + assertThat(anime.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + "Cader${input}no da Mor${input}te", + ) + + // then + assertThat(anime.synonyms).containsExactly( + "Caderno da Morte", + ) + } + + @Test + fun `remove leading whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms( + " $expectedTitleOne", + " $expectedTitleTwo", + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `remove tailing whitespace from synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms( + "$expectedTitleOne ", + "$expectedTitleTwo ", + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in synonyms`() { + // given + val expectedTitleOne = "Death Note" + val expectedTitleTwo = "Made in Abyss" + val anime = AnimeRaw("Title") + + // when + anime.addSynonyms( + "Death Note", + "Made in Abyss", + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + expectedTitleOne, + expectedTitleTwo, + ) + } + + @Test + fun `successfully add synonyms`() { + // given + val one = "Caderno da Morte" + val two = "DN" + val three = "Quaderno della Morte" + val four = "Sveska Smrti" + + val anime = AnimeRaw("Death Note") + + // when + anime.addSynonyms( + four, + two, + three, + one, + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + three, + four, + ) + } + + @Test + fun `must not add a duplicated synonym`() { + // given + val one = "Caderno da Morte" + val two = "DN" + + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + two, + ), + ) + + // when + anime.addSynonyms( + two, + one, + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + one, + two, + ) + } + + @Test + fun `synonym comparison to title is case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw(title) + + // when + anime.addSynonyms( + title.uppercase(), + title.lowercase(), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title.uppercase(), + title.lowercase(), + ) + } + + @Test + fun `synonym comparison is not case-sensitive`() { + // given + val title = "Death Note" + val anime = AnimeRaw("デスノート") + + // when + anime.addSynonyms( + title, + title.lowercase(), + title.uppercase(), + ) + + // then + assertThat(anime.synonyms).containsExactlyInAnyOrder( + title, + title.uppercase(), + title.lowercase(), + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she\u2019s, John\u2019s, it\u2019s", + "the girls\u2019, the dogs\u2019", + "don\u2019t, can\u2019t", + "I\u2019m", + "I\u2019d, they\u2019d", + "rock \u2019n\u2019 roll", + "he\u2019ll, we\u2019ll", + "your\u2019re", + "I\u2019ve", + "\u2019ere", + "\u2019cause", + "\u2019bout", + "\u2019fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addSynonyms( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class RemoveSynonymIfTests { + + @Test + fun `successfully remove synonym`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Notatnik smierci", + ), + ) + + // when + anime.removeSynonymIf { it == "Notatnik smierci" } + + // then + assertThat(anime.synonyms).isEmpty() + } + + @Test + fun `don't remove anything if condition doesn't match`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Notatnik smierci", + ), + ) + + // when + anime.removeSynonymIf { it.contains("Bleach") } + + // then + assertThat(anime.synonyms).containsExactly( + "Notatnik smierci", + ) + } + } + } + + @Nested + inner class StudiosTest { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + // when + anime.studios.clear() + + // then + assertThat(anime.studios).isNotEmpty() + } + + @Nested + inner class AddStudiosConstructorTests { + + @Test + fun `studios added by constructor are set to lower case`() { + // given + val studio = "EXAMPLE" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio, + ), + ) + + // then + assertThat(result.studios).containsExactly( + studio.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from studios`() { + // given + val expectedStudio = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + " $expectedStudio", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `remove tailing whitespace from studios`() { + // given + val expectedStudio = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "$expectedStudio ", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in studios`() { + // given + val expectedStudio = "bibury animation studios" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "bibury animation studios", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.studios).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedStudio = "bibury animation studios" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "bibury${input}animation${input}studios", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedStudio = "bibury animation studios" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "bi${input}bury ani${input}mation stu${input}dios", + ), + ) + + // then + assertThat(result.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val studio = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf(), + ) + + // then + assertThat(result.studios).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val studio1 = "a studio" + val studio2 = "before the other" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val studio1 = "satelight" + val studio2 = "satelight inc." + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val studio1 = "gallop" + val studio2 = "studio gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + studio3, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "tokyo HQ gallop" + val studio4 = "gallop made up" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio2, + studio1, + studio3, + studio4, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val studio1 = "anime beans" + val studio2 = "tate anime" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val studio1 = "studio durian" + val studio2 = "wit studio" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val studio1 = "sotsu" + val studio2 = "square enix" + val studio3 = "tv aichi" + val studio4 = "tv osaka" + val studio5 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val studio1 = "tokyo kids" + val studio2 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val studio1 = "groove corporation" + val studio2 = "group tac" + val studio3 = "hitsuji no uta production committee" + val studio4 = "inter communications inc." + val studio5 = "ken groove" + val studio6 = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val studio1 = "kitty media" + val studio2 = "media blasters" + val studio3 = "on-lead" + val studio4 = "venet" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val studio1 = "009 re:cyborg production committee" + val studio2 = "amazonlaterna co,ltd." + val studio3 = "funimation" + val studio4 = "ishimori production" + val studio5 = "nippon television network corporation" + val studio6 = "production i.g" + val studio7 = "sanzigen" + val studio8 = "t-joy" + val studio9 = "vap" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val studio1 = "animation do" + val studio2 = "chu-2byo production partners" + val studio3 = "kyoto animation" + val studio4 = "lantis" + val studio5 = "pony canyon" + val studio6 = "rakuonsha" + val studio7 = "tbs" + val studio8 = "tokyo broadcasting system" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val studio1 = "chiba television" + val studio2 = "geneon universal entertainment" + val studio3 = "kbs kyoto" + val studio4 = "medil" + val studio5 = "mie television broadcasting" + val studio6 = "sun tv" + val studio7 = "television kanagawa" + val studio8 = "television saitama co., ltd." + val studio9 = "thefool" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val studio1 = "group tac" + val studio2 = "nippon herald film group" + val studio3 = "the asahi shimbun company" + val studio4 = "tv asahi" + + // when + val result = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + studio1, + studio2, + studio3, + studio4, + ), + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _studios = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _studios = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) + fun `doesn't fix studios if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _studios = hashSetOf( + value, + ), + activateChecks = false, + ) + + // then + assertThat(obj.studios).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddStudiosTests { + + @Test + fun `studios added are set to lower case`() { + // given + val studio = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio, + )) + + // then + assertThat(anime.studios).containsExactly( + studio.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from studios`() { + // given + val expectedStudio = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + " $expectedStudio", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `remove tailing whitespace from studios`() { + // given + val expectedStudios = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "$expectedStudios ", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudios, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in studios`() { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "bibury animation studios", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "$input $input", + )) + + // then + assertThat(anime.studios).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "bibury${input}animation${input}studios", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + "bi${input}bury ani${input}mation stu${input}dios", + )) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val studio = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf()) + + // then + assertThat(anime.studios).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val studio1 = "a studio" + val studio2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val studio1 = "satelight" + val studio2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val studio1 = "gallop" + val studio2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio2, + studio1, + studio3, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "tokyo HQ gallop" + val studio4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val studio1 = "anime beans" + val studio2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val studio1 = "studio durian" + val studio2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val studio1 = "sotsu" + val studio2 = "square enix" + val studio3 = "tv aichi" + val studio4 = "tv osaka" + val studio5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val studio1 = "tokyo kids" + val studio2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val studio1 = "groove corporation" + val studio2 = "group tac" + val studio3 = "hitsuji no uta production committee" + val studio4 = "inter communications inc." + val studio5 = "kenmedia" + val studio6 = "ken groove" + val studio7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val studio1 = "kitty media" + val studio2 = "media blasters" + val studio3 = "on-lead" + val studio4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val studio1 = "009 re:cyborg production committee" + val studio2 = "amazonlaterna co,ltd." + val studio3 = "funimation" + val studio4 = "ishimori production" + val studio5 = "nippon television network corporation" + val studio6 = "production i.g" + val studio7 = "sanzigen" + val studio8 = "t-joy" + val studio9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val studio1 = "animation do" + val studio2 = "chu-2byo production partners" + val studio3 = "kyoto animation" + val studio4 = "lantis" + val studio5 = "pony canyon" + val studio6 = "rakuonsha" + val studio7 = "tbs" + val studio8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val studio1 = "chiba television" + val studio2 = "geneon universal entertainment" + val studio3 = "kbs kyoto" + val studio4 = "medil" + val studio5 = "mie television broadcasting" + val studio6 = "sun tv" + val studio7 = "television kanagawa" + val studio8 = "television saitama co., ltd." + val studio9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val studio1 = "group tac" + val studio2 = "nippon herald film group" + val studio3 = "the asahi shimbun company" + val studio4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(listOf( + studio1, + studio2, + studio3, + studio4, + )) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios(listOf( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddStudiosVarargTests { + + @Test + fun `studios added are set to lower case`() { + // given + val studio = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addStudios(studio) + + // then + assertThat(anime.studios).containsExactly( + studio.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from studios`() { + // given + val expectedStudio = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + " $expectedStudio", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `remove tailing whitespace from studios`() { + // given + val expectedStudio = "example" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "$expectedStudio ", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in studios`() { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "bibury animation studios", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "$input $input", + ) + + // then + assertThat(anime.studios).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "bibury${input}animation${input}studios", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedStudio = "bibury animation studios" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + "bi${input}bury ani${input}mation stu${input}dios", + ) + + // then + assertThat(anime.studios).containsExactly( + expectedStudio, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val studio = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addStudios() + + // then + assertThat(anime.studios).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val studio1 = "a studio" + val studio2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio2, + studio1, + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val studio1 = "satelight" + val studio2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val studio1 = "gallop" + val studio2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio3, + studio2, + studio1, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val studio1 = "gallop co., ltd." + val studio2 = "studio gallop" + val studio3 = "tokyo HQ gallop" + val studio4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val studio1 = "anime beans" + val studio2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val studio1 = "studio durian" + val studio2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val studio1 = "sotsu" + val studio2 = "square enix" + val studio3 = "tv aichi" + val studio4 = "tv osaka" + val studio5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val studio1 = "tokyo kids" + val studio2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val studio1 = "groove corporation" + val studio2 = "group tac" + val studio3 = "hitsuji no uta production committee" + val studio4 = "inter communications inc." + val studio5 = "kenmedia" + val studio6 = "ken groove" + val studio7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val studio1 = "kitty media" + val studio2 = "media blasters" + val studio3 = "on-lead" + val studio4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val studio1 = "009 re:cyborg production committee" + val studio2 = "amazonlaterna co,ltd." + val studio3 = "funimation" + val studio4 = "ishimori production" + val studio5 = "nippon television network corporation" + val studio6 = "production i.g" + val studio7 = "sanzigen" + val studio8 = "t-joy" + val studio9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val studio1 = "animation do" + val studio2 = "chu-2byo production partners" + val studio3 = "kyoto animation" + val studio4 = "lantis" + val studio5 = "pony canyon" + val studio6 = "rakuonsha" + val studio7 = "tbs" + val studio8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val studio1 = "chiba television" + val studio2 = "geneon universal entertainment" + val studio3 = "kbs kyoto" + val studio4 = "medil" + val studio5 = "mie television broadcasting" + val studio6 = "sun tv" + val studio7 = "television kanagawa" + val studio8 = "television saitama co., ltd." + val studio9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + studio5, + studio6, + studio7, + studio8, + studio9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val studio1 = "group tac" + val studio2 = "nippon herald film group" + val studio3 = "the asahi shimbun company" + val studio4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addStudios( + studio1, + studio2, + studio3, + studio4, + ) + + // then + assertThat(anime.studios).containsExactlyInAnyOrder( + studio1, + studio2, + studio3, + studio4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addStudios( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + } + + @Nested + inner class ProducersTest { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + // when + anime.producers.clear() + + // then + assertThat(anime.producers).isNotEmpty() + } + + @Nested + inner class AddProducersConstructorTests { + + @Test + fun `producers added by constructor are set to lower case`() { + // given + val producer = "EXAMPLE" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer, + ), + ) + + // then + assertThat(result.producers).containsExactly( + producer.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from producers`() { + // given + val expectedProducer = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + " $expectedProducer", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `remove tailing whitespace from producers`() { + // given + val expectedProducer = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "$expectedProducer ", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in producers`() { + // given + val expectedProducer = "pony canyon" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "pony canyon", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add producer if it's a blank string`(input: String) { + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.producers).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedProducer = "pony canyon" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "pony${input}canyon", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedProducer = "pony canyon" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "po${input}ny can${input}yon", + ), + ) + + // then + assertThat(result.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val producer = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf(), + ) + + // then + assertThat(result.producers).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val producer1 = "a producer" + val producer2 = "before the other" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer1, + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val producer1 = "satelight" + val producer2 = "satelight inc." + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer1, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val producer1 = "gallop" + val producer2 = "studio gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer1, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "gallop" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer2, + producer3, + producer1, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "tokyo HQ gallop" + val producer4 = "gallop made up" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val producer1 = "anime beans" + val producer2 = "tate anime" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val producer1 = "studio durian" + val producer2 = "wit studio" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val producer1 = "sotsu" + val producer2 = "square enix" + val producer3 = "tv aichi" + val producer4 = "tv osaka" + val producer5 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val producer1 = "tokyo kids" + val producer2 = "tv tokyo" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val producer1 = "groove corporation" + val producer2 = "group tac" + val producer3 = "hitsuji no uta production committee" + val producer4 = "inter communications inc." + val producer5 = "kenmedia" + val producer6 = "ken groove" + val producer7 = "madhouse" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val producer1 = "kitty media" + val producer2 = "media blasters" + val producer3 = "on-lead" + val producer4 = "venet" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val producer1 = "009 re:cyborg production committee" + val producer2 = "amazonlaterna co,ltd." + val producer3 = "funimation" + val producer4 = "ishimori production" + val producer5 = "nippon television network corporation" + val producer6 = "production i.g" + val producer7 = "sanzigen" + val producer8 = "t-joy" + val producer9 = "vap" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val producer1 = "animation do" + val producer2 = "chu-2byo production partners" + val producer3 = "kyoto animation" + val producer4 = "lantis" + val producer5 = "pony canyon" + val producer6 = "rakuonsha" + val producer7 = "tbs" + val producer8 = "tokyo broadcasting system" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val producer1 = "chiba television" + val producer2 = "geneon universal entertainment" + val producer3 = "kbs kyoto" + val producer4 = "medil" + val producer5 = "mie television broadcasting" + val producer6 = "sun tv" + val producer7 = "television kanagawa" + val producer8 = "television saitama co., ltd." + val producer9 = "thefool" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val producer1 = "group tac" + val producer2 = "nippon herald film group" + val producer3 = "the asahi shimbun company" + val producer4 = "tv asahi" + + // when + val result = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + producer1, + producer2, + producer3, + producer4, + ), + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _producers = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _producers = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) + fun `doesn't fix producers if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _producers = hashSetOf( + value, + ), + activateChecks = false, + ) + + // then + assertThat(obj.producers).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddProducersTests { + + @Test + fun `producers added are set to lower case`() { + // given + val producer = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer, + )) + + // then + assertThat(anime.producers).containsExactly( + producer.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + " $expectedProducer", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `remove tailing whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "$expectedProducer ", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in producers`() { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "pony canyon", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add studios if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "$input $input", + )) + + // then + assertThat(anime.producers).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "pony${input}canyon", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + "po${input}ny can${input}yon", + )) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val producer = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf()) + + // then + assertThat(anime.producers).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val producer1 = "a producer" + val producer2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer2, + producer1, + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val producer1 = "satelight" + val producer2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val producer1 = "gallop" + val producer2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer2, + producer3, + producer1, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple cases`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "tokyo HQ gallop" + val producer4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val producer1 = "anime beans" + val producer2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val producer1 = "studio durian" + val producer2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val producer1 = "sotsu" + val producer2 = "square enix" + val producer3 = "tv aichi" + val producer4 = "tv osaka" + val producer5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val producer1 = "tokyo kids" + val producer2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val producer1 = "groove corporation" + val producer2 = "group tac" + val producer3 = "hitsuji no uta production committee" + val producer4 = "inter communications inc." + val producer5 = "kenmedia" + val producer6 = "ken groove" + val producer7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val producer1 = "kitty media" + val producer2 = "media blasters" + val producer3 = "on-lead" + val producer4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val producer1 = "009 re:cyborg production committee" + val producer2 = "amazonlaterna co,ltd." + val producer3 = "funimation" + val producer4 = "ishimori production" + val producer5 = "nippon television network corporation" + val producer6 = "production i.g" + val producer7 = "sanzigen" + val producer8 = "t-joy" + val producer9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val producer1 = "animation do" + val producer2 = "chu-2byo production partners" + val producer3 = "kyoto animation" + val producer4 = "lantis" + val producer5 = "pony canyon" + val producer6 = "rakuonsha" + val producer7 = "tbs" + val producer8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val producer1 = "chiba television" + val producer2 = "geneon universal entertainment" + val producer3 = "kbs kyoto" + val producer4 = "medil" + val producer5 = "mie television broadcasting" + val producer6 = "sun tv" + val producer7 = "television kanagawa" + val producer8 = "television saitama co., ltd." + val producer9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val producer1 = "group tac" + val producer2 = "nippon herald film group" + val producer3 = "the asahi shimbun company" + val producer4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(listOf( + producer1, + producer2, + producer3, + producer4, + )) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddProducersVarargTests { + + @Test + fun `producers added are set to lower case`() { + // given + val producer = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addProducers(producer) + + // then + assertThat(anime.producers).containsExactly( + producer.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + " $expectedProducer", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `remove tailing whitespace from producers`() { + // given + val expectedProducer = "example" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "$expectedProducer ", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in producers`() { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "pony canyon", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `don't add producers if it's a blank string`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "$input $input", + ) + + // then + assertThat(anime.producers).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "pony${input}canyon", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedProducer = "pony canyon" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + "po${input}ny can${input}yon", + ) + + // then + assertThat(anime.producers).containsExactly( + expectedProducer, + ) + } + + @Test + fun `correctly adds a single value`() { + // given + val producer = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer, + ) + } + + @Test + fun `does nothing in case of an empty list`() { + // given + val anime = AnimeRaw("Test") + + // when + anime.addProducers() + + // then + assertThat(anime.producers).isEmpty() + } + + @Test + fun `prevent duplicates`() { + // given + val producer1 = "a producer" + val producer2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer2, + producer1, + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same prefix`() { + // given + val producer1 = "satelight" + val producer2 = "satelight inc." + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent duplicates between longer and shorter versions of the same studio name - same suffix`() { + // given + val producer1 = "gallop" + val producer2 = "studio gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer2, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - single case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "gallop" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent overlapping duplicates where the last part of a name and the first part of a name identify duplicates - multiple case`() { + // given + val producer1 = "gallop co., ltd." + val producer2 = "studio gallop" + val producer3 = "tokyo HQ gallop" + val producer4 = "gallop made up" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIME`() { + // given + val producer1 = "anime beans" + val producer2 = "tate anime" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - STUDIO`() { + // given + val producer1 = "studio durian" + val producer2 = "wit studio" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TV`() { + // given + val producer1 = "sotsu" + val producer2 = "square enix" + val producer3 = "tv aichi" + val producer4 = "tv osaka" + val producer5 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TOKYO`() { + // given + val producer1 = "tokyo kids" + val producer2 = "tv tokyo" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROOVE`() { + // given + val producer1 = "groove corporation" + val producer2 = "group tac" + val producer3 = "hitsuji no uta production committee" + val producer4 = "inter communications inc." + val producer5 = "kenmedia" + val producer6 = "ken groove" + val producer7 = "madhouse" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - MEDIA`() { + // given + val producer1 = "kitty media" + val producer2 = "media blasters" + val producer3 = "on-lead" + val producer4 = "venet" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - PRODUCTION`() { + // given + val producer1 = "009 re:cyborg production committee" + val producer2 = "amazonlaterna co,ltd." + val producer3 = "funimation" + val producer4 = "ishimori production" + val producer5 = "nippon television network corporation" + val producer6 = "production i.g" + val producer7 = "sanzigen" + val producer8 = "t-joy" + val producer9 = "vap" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - ANIMATION`() { + // given + val producer1 = "animation do" + val producer2 = "chu-2byo production partners" + val producer3 = "kyoto animation" + val producer4 = "lantis" + val producer5 = "pony canyon" + val producer6 = "rakuonsha" + val producer7 = "tbs" + val producer8 = "tokyo broadcasting system" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - TELEVISION`() { + // given + val producer1 = "chiba television" + val producer2 = "geneon universal entertainment" + val producer3 = "kbs kyoto" + val producer4 = "medil" + val producer5 = "mie television broadcasting" + val producer6 = "sun tv" + val producer7 = "television kanagawa" + val producer8 = "television saitama co., ltd." + val producer9 = "thefool" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + producer5, + producer6, + producer7, + producer8, + producer9, + ) + } + + @Test + fun `prevent false positive for identifying overlapping duplicates - GROUP`() { + // given + val producer1 = "group tac" + val producer2 = "nippon herald film group" + val producer3 = "the asahi shimbun company" + val producer4 = "tv asahi" + val anime = AnimeRaw("Test") + + // when + anime.addProducers( + producer1, + producer2, + producer3, + producer4, + ) + + // then + assertThat(anime.producers).containsExactlyInAnyOrder( + producer1, + producer2, + producer3, + producer4, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addProducers( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _sources = hashSetOf( + URI("https://example.org/anime/1535"), + URI("https://myanimelist.net/anime/1535"), + ), + ) + + // when + anime.sources.clear() + + // then + assertThat(anime.sources).isNotEmpty() + } + + @Nested + inner class AddSourcesConstructorTests { + + @Test + fun `add source`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // then + assertThat(anime.sources).containsExactly(source) + } + + @Test + fun `cannot add duplicated source link`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + source, + ), + ) + + // then + assertThat(result.sources).containsExactly(source) + } + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + ) + + // then + assertThat(result.sources).containsExactly( + source, + ) + assertThat(result.relatedAnime).isEmpty() + } + + @Test + fun `don't remove related anime if activateChecks is false`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + activateChecks = false, + ) + + // then + assertThat(result.sources).containsExactly( + source, + ) + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(source), + ) + } + } + + @Nested + inner class AddSourcesTests { + + @Test + fun `add source`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addSources(listOf( + source, + )) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `cannot add duplicated source link`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.addSources(listOf( + source, + )) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + ) + + // when + anime.addSources(listOf( + source, + )) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class AddSourcesVarargTests { + + @Test + fun `add source`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addSources( + source, + ) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `cannot add duplicated source link`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.addSources(source) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + ) + + // when + anime.addSources(source) + + // then + assertThat(anime.sources).containsExactly( + source, + ) + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class RemoveSourceIfTests { + + @Test + fun `successfully remove source`() { + // given + val source = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.removeSourceIf { it.toString() == "https://myanimelist.net/anime/2994" } + + // then + assertThat(anime.sources).isEmpty() + } + + @Test + fun `don't remove anything if the condition doesn't match`() { + // given + val source = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + ) + + // when + anime.removeSourceIf { it.toString().contains("anidb.net") } + + // then + assertThat(anime.sources).containsExactly( + source, + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Nested + inner class AddRelatedAnimeConstructorTests { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://example.org/anime/1535")), + RelatedAnime(URI("https://myanimelist.net/anime/1535")), + ), + ) + + // when + anime.relatedAnime.clear() + + // then + assertThat(anime.relatedAnime).isNotEmpty() + } + + @Test + fun `add related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + + // when + val result = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add duplicated link for related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + + // when + val result = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + RelatedAnime(relatedAnime), + ), + ) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add a related anime if the links is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + + // when + val result = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + _relatedAnime = hashSetOf( + RelatedAnime(link), + ), + ) + + // then + assertThat(result.relatedAnime).isEmpty() + } + + @Test + fun `doesn't remove sources from relatedAnimeif activateChecks is false`() { + // when + val obj = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/6351"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2167")), + RelatedAnime(URI("https://myanimelist.net/anime/6351")), + ), + activateChecks = false, + ) + + // then + assertThat(obj.relatedAnime).hasSize(2) + } + } + + @Nested + inner class AddRelatedAnimeTests { + + @Test + fun `add related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addRelatedAnime(listOf( + relatedAnime, + )) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add duplicated link for related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.addRelatedAnime(listOf( + relatedAnime, + )) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add a related anime if the links is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + ) + + // when + anime.addRelatedAnime(listOf( + link, + )) + + // then + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class AddRelatedAnimeVarargTests { + + @Test + fun `add related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addRelatedAnime( + relatedAnime, + ) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add duplicated link for related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.addRelatedAnime( + relatedAnime, + ) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + + @Test + fun `cannot add a related anime if the links is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + ) + + // when + anime.addRelatedAnime(link) + + // then + assertThat(anime.relatedAnime).isEmpty() + } + } + + @Nested + inner class AddRelatedAnimeWithRelationTests { + + @Test + fun `add related anime including the kind of relation`() { + // given + val relatedAnime = RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary") + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + anime.addRelatedAnime(relatedAnime) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ) + } + + @Test + fun `an entry having a relation replaces an entry for the same link without relation`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + // when + anime.addRelatedAnime(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ) + } + + @Test + fun `an entry without relation doesn't replace an entry for the same link having a relation`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ), + ) + + // when + anime.addRelatedAnime(URI("https://myanimelist.net/anime/2994")) + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + ) + } + + @Test + fun `keeps only one entry per link`() { + // when + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Other"), + ), + ) + + // then + assertThat(anime.relatedAnime).hasSize(1) + } + + @Test + fun `cannot add a related anime having a relation if the link is already part of the sources`() { + // given + val link = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + link, + ), + ) + + // when + anime.addRelatedAnime(RelatedAnime(link, "Sequel")) + + // then + assertThat(anime.relatedAnime).isEmpty() + } + + @Test + fun `merging keeps the kind of relation`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://myanimelist.net/anime/1535")), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary")), + ) + val other = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://anilist.co/anime/2994"), "SUMMARY")), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://myanimelist.net/anime/2994"), "Summary"), + RelatedAnime(URI("https://anilist.co/anime/2994"), "SUMMARY"), + ) + } + } + + @Nested + inner class RemoveRelationIfTests { + + @Test + fun `successfully remove related anime`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.removeRelatedAnimeIf { it.toString() == "https://myanimelist.net/anime/2994" } + + // then + assertThat(anime.relatedAnime).isEmpty() + } + + @Test + fun `don't remove anything if condition doesn't match`() { + // given + val relatedAnime = URI("https://myanimelist.net/anime/2994") + val anime = AnimeRaw( + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(relatedAnime), + ), + ) + + // when + anime.removeRelatedAnimeIf { it.toString().contains("anidb.net") } + + // then + assertThat(anime.relatedAnime).containsExactly( + RelatedAnime(relatedAnime), + ) + } + } + } + + @Nested + inner class EqualityTests { + + @Test + fun `is equal if titles are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + ) + + val b = AnimeRaw( + _title = title, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if titles are different`() { + // given + val a = AnimeRaw( + _title = "Death Note", + ) + + val b = AnimeRaw( + _title = "デスノート", + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if source links are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + ) + + val b = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if source links are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + ) + + val b = AnimeRaw( + _title = title, + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + URI("https://anidb.net/anime/4563"), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if synonyms are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + val b = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if synonyms are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + val b = AnimeRaw( + _title = title, + _synonyms = hashSetOf( + "Caderno da Morte", + "Quaderno della Morte", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if types are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + type = TV, + ) + + val b = AnimeRaw( + _title = title, + type = TV, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if types are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + type = TV, + ) + + val b = AnimeRaw( + _title = title, + type = MOVIE, + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if episodes are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + episodes = 37, + ) + + val b = AnimeRaw( + _title = title, + episodes = 37, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if episodes are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + episodes = 37, + ) + + val b = AnimeRaw( + _title = title, + episodes = 1, + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if status is the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + status = FINISHED, + ) + + val b = AnimeRaw( + _title = title, + status = FINISHED, + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if status is different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + status = FINISHED, + ) + + val b = AnimeRaw( + _title = title, + status = UNKNOWN_STATUS, + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if animeSeasons are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = FALL, + year = 2006, + ), + ) + + val b = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = FALL, + year = 2006, + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if animeSeasons are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = UNDEFINED, + year = 2006, + ), + ) + + val b = AnimeRaw( + _title = title, + animeSeason = AnimeSeason( + season = FALL, + year = 2006, + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if pictures are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + picture = URI("https://example.org/pic/1.png"), + ) + + val b = AnimeRaw( + _title = title, + picture = URI("https://example.org/pic/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if pictures are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + picture = URI("https://example.org/pictures/1.png"), + ) + + val b = AnimeRaw( + _title = title, + picture = URI("https://example.org/pic/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if thumbnail are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnail/1.png"), + ) + + val b = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnail/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if thumbnail are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnails/1.png"), + ) + + val b = AnimeRaw( + _title = title, + thumbnail = URI("https://example.org/thumbnail/1.png"), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if durations are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + duration = Duration(20, MINUTES), + ) + + val b = AnimeRaw( + _title = title, + duration = Duration(20, MINUTES), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if durations are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + duration = Duration(21, MINUTES), + ) + + val b = AnimeRaw( + _title = title, + duration = Duration(20, MINUTES), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if scores are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + val b = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if scores are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + val b = AnimeRaw( + _title = title, + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + MetaDataProviderScoreValue( + hostname = "other.com", + value = 9.50, + range = 1.0..10.0, + ) + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if related anime are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + val b = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if related anime are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + val b = AnimeRaw( + _title = title, + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + RelatedAnime(URI("http://anilist.co/anime/2994")), + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if tags are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _tags = hashSetOf( + "comedy", + "slice of life", + ), + ) + + val b = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + "comedy", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if tags are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + ), + ) + + val b = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + "comedy", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if studios are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if studios are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _studios = hashSetOf( + "studio 1", + "studio 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is equal if producers are the same`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isTrue() + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if producers are different`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 2", + ), + ) + + val b = AnimeRaw( + _title = title, + _producers = hashSetOf( + "producer 1", + "producer 2", + ), + ) + + // when + val result = a == b + + // then + assertThat(result).isFalse() + assertThat(a.hashCode()).isNotEqualTo(b.hashCode()) + } + + @Test + fun `is not equal if the other object is of a different type`() { + // given + val title = "Death Note" + val a = AnimeRaw( + _title = title, + _tags = hashSetOf( + "slice of life", + ), + ) + + // when + val result = a.equals(1) + + // then + assertThat(result).isFalse() + } + } + + @Nested + inner class MergeTests { + + @Test + fun `use this number of episodes` () { + // given + val anime = AnimeRaw( + _title = "this", + episodes = 12, + ) + val other = AnimeRaw( + _title = "other", + episodes = 13, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.episodes).isEqualTo(12) + } + + @Test + fun `use other's number of episodes if this number of episodes is 0` () { + // given + val anime = AnimeRaw( + _title = "this", + episodes = 0, + ) + val other = AnimeRaw( + _title = "other", + episodes = 13, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.episodes).isEqualTo(13) + } + + @Test + fun `use this type` () { + // given + val anime = AnimeRaw( + _title = "this", + type = MOVIE, + ) + val other = AnimeRaw( + _title = "other", + type = SPECIAL, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + + @Test + fun `use other's type if this type is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + type = UNKNOWN_TYPE, + ) + val other = AnimeRaw( + _title = "other", + type = SPECIAL, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + + @Test + fun `use this status` () { + // given + val anime = AnimeRaw( + _title = "this", + status = FINISHED, + ) + val other = AnimeRaw( + _title = "other", + status = ONGOING, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + + @Test + fun `use other's status if this status is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + status = UNKNOWN_STATUS, + ) + val other = AnimeRaw( + _title = "other", + status = ONGOING, + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + + @Test + fun `use this duration` () { + // given + val anime = AnimeRaw( + _title = "this", + duration = Duration(120, MINUTES), + ) + val other = AnimeRaw( + _title = "other", + duration = Duration(125, MINUTES), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.duration).isEqualTo(Duration(120, MINUTES)) + } + + @Test + fun `use other's duration if this duration is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + duration = UNKNOWN_DURATION, + ) + val other = AnimeRaw( + _title = "other", + duration = Duration(125, MINUTES), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.duration).isEqualTo(Duration(125, MINUTES)) + } + + @Test + fun `use this season` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = FALL, + year = 2010, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + assertThat(result.animeSeason.year).isEqualTo(2010) + } + + @Test + fun `use other's season if this season is UNDEFINED` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = UNDEFINED, + year = 2010, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + assertThat(result.animeSeason.year).isEqualTo(2010) + } + + @Test + fun `use this year` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = FALL, + year = 2010, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + assertThat(result.animeSeason.year).isEqualTo(2010) + } + + @Test + fun `use other's year if this year is UNKNOWN` () { + // given + val anime = AnimeRaw( + _title = "this", + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + ) + val other = AnimeRaw( + _title = "other", + animeSeason = AnimeSeason( + season = WINTER, + year = 2011, + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + assertThat(result.animeSeason.year).isEqualTo(2011) + } + + @Test + fun `add title and synonyms of the other anime to this anime's synonyms`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + val other = AnimeRaw( + _title = "DEATH NOTE", + _synonyms = hashSetOf( + "Caderno da Morte", + "Quaderno della Morte", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.title).isEqualTo(anime.title) + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Caderno da Morte", + "DEATH NOTE", + "Quaderno della Morte", + ) + } + + @Test + fun `merge related anime and source links`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.sources).containsExactlyInAnyOrder( + URI("https://anidb.net/anime/4563"), + URI("https://myanimelist.net/anime/1535"), + ) + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ) + } + + @Test + fun `merge tags`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _tags = hashSetOf( + "Psychological", + "Thriller", + "Shounen", + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _tags = hashSetOf( + "Mystery", + "Police", + "Psychological", + "Supernatural", + "Thriller", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "mystery", + "police", + "psychological", + "shounen", + "supernatural", + "thriller", + ) + } + + @Test + fun `merge studios`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _studios = hashSetOf( + "studio 1" + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _studios = hashSetOf( + "studio 2", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "studio 1", + "studio 2", + ) + } + + @Test + fun `merge producers`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + _producers = hashSetOf( + "producer 1" + ), + ) + + val other = AnimeRaw( + _title = "Death Note", + _producers = hashSetOf( + "producer 2", + ), + ) + + // when + val result = anime.mergeWith(other) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "producer 1", + "producer 2", + ) + } + + @Test + fun `merge scores`() { + // given + val title = "Death Note" + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val a = AnimeRaw( + _title = title, + ).addScores( + scoreA, + ) + + val scoreB = MetaDataProviderScoreValue( + hostname = "other.com", + value = 9.5, + range = 1.0..10.0, + ) + val b = AnimeRaw( + _title = title, + ).addScores( + scoreB, + ) + + // when + val result = a.mergeWith(b) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreA, + scoreB, + ) + } + + @Test + fun `overrides score entries with same hostname`() { + // given + val title = "Death Note" + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 2.41, + range = 1.0..5.0, + ) + val a = AnimeRaw( + _title = title, + ).addScores( + scoreA, + ) + + val scoreB = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val b = AnimeRaw( + _title = title, + ).addScores( + scoreB, + ) + + // when + val result = a.mergeWith(b) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreB, + ) + } + } + + @Nested + inner class TagsTest { + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + _tags = hashSetOf( + "thriller", + "acion", + ), + ) + + // when + anime.tags.clear() + + // then + assertThat(anime.tags).isNotEmpty() + } + + @Nested + inner class AddTagsConstructorTests { + + @Test + fun `tags added by constructor are set to lower case`() { + // given + val tag = "EXAMPLE" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + tag, + ), + ) + + // then + assertThat(result.tags).containsExactly( + tag.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from tags`() { + // given + val expectedTag = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + " $expectedTag", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `remove tailing whitespace from tags`() { + // given + val expectedTag = "example" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "$expectedTag ", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in tags`() { + // given + val expectedTag = "slice of life" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "slice of life", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank tag`(input: String) { + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "$input $input", + ), + ) + + // then + assertThat(result.tags).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTag = "slice of life" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "slice${input}of${input}life", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTag = "slice of life" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "sli${input}ce of li${input}fe", + ), + ) + + // then + assertThat(result.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `prevent duplicates`() { + // given + val tag1 = "a tag" + val tag2 = "before the other" + + // when + val result = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + tag2, + tag1, + tag1, + tag2, + ), + ) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + tag1, + tag2, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _tags = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // when + val anime = AnimeRaw( + _title = "Main title", + _tags = hashSetOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ), + ) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE", "", " ", " ", "\u200C"]) + fun `doesn't fix tags if activateChecks is false`(value: String) { + // when + val obj = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + value, + ), + activateChecks = false, + ) + + // then + assertThat(obj.tags).containsExactlyInAnyOrder( + value, + ) + } + } + + @Nested + inner class AddTagsTests { + + @Test + fun `tags added are set to lower case`() { + // given + val tag = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + tag, + )) + + // then + assertThat(anime.tags).containsExactly( + tag.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + " $expectedTag", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `remove tailing whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "$expectedTag ", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in tags`() { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "slice of life", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank tag`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "$input $input", + )) + + // then + assertThat(anime.tags).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "slice${input}of${input}life", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + "sli${input}ce o${input}f li${input}fe", + )) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `prevent duplicates`() { + // given + val tag1 = "a tag" + val tag2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addTags(listOf( + tag2, + tag1, + tag1, + tag2, + )) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + tag1, + tag2, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags(listOf( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + )) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + + @Nested + inner class AddTagsVarargTests { + + @Test + fun `tags added are set to lower case`() { + // given + val tag = "EXAMPLE" + val anime = AnimeRaw("Test") + + // when + anime.addTags(tag) + + // then + assertThat(anime.tags).containsExactly( + tag.lowercase(), + ) + } + + @Test + fun `remove leading whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + " $expectedTag", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `remove tailing whitespace from tags`() { + // given + val expectedTag = "example" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "$expectedTag ", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `replace multiple whitespaces with a single whitespace in tags`() { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "slice of life", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `must not add blank tag`(input: String) { + // given + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "$input $input", + ) + + // then + assertThat(anime.tags).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + ]) + fun `normalize visible whitespaces to a normale whitespace`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "slice${input}of${input}life", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + ]) + fun `remove non-visible whitespaces`(input: String) { + // given + val expectedTag = "slice of life" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + "sli${input}ce o${input}f li${input}fe", + ) + + // then + assertThat(anime.tags).containsExactly( + expectedTag, + ) + } + + @Test + fun `prevent duplicates`() { + // given + val tag1 = "a tag" + val tag2 = "before the other" + val anime = AnimeRaw("Test") + + // when + anime.addTags( + tag2, + tag1, + tag1, + tag2, + ) + + // then + assertThat(anime.tags).containsExactlyInAnyOrder( + tag1, + tag2, + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u2019", + "\u02BB", + "\u02BC", + "\u2018", + "\u275B", + "\u275C", + "\u02B9", + "\u02BE", + "\u02C8", + "\u055A", + "\u07F4", + "\u07F5", + "\u1FBF", + "\u2032", + "\uA78C", + "\uFF07", + "\u0060", + ]) + fun `normalizes any combination of preceding apostrophe followed by s or s followed by apostrophe`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she\u0027s, john\u0027s, it\u0027s", + "the girls\u0027, the dogs\u0027", + "don\u0027t, can\u0027t", + "i\u0027m", + "i\u0027d, they\u0027d", + "rock \u0027n\u0027 roll", + "he\u0027ll, we\u0027ll", + "your\u0027re", + "i\u0027ve", + "\u0027ere", + "\u0027cause", + "\u0027bout", + "\u0027fore", + ) + } + + @ParameterizedTest + @ValueSource(strings = [ + "\u0027", + ]) + fun `doesn't normalize acceptable apostrophes`(value: String) { + // given + val anime = AnimeRaw( + _title = "Main title", + ) + + // when + val result = anime.addTags( + "she${value}s, John${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "I${value}m", + "I${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "I${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "she${value}s, john${value}s, it${value}s", + "the girls$value, the dogs${value}", + "don${value}t, can${value}t", + "i${value}m", + "i${value}d, they${value}d", + "rock ${value}n$value roll", + "he${value}ll, we${value}ll", + "your${value}re", + "i${value}ve", + "${value}ere", + "${value}cause", + "${value}bout", + "${value}fore", + ) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `default duration is 0 seconds`() { + // given + val expectedDuration = Duration(0, SECONDS) + + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.duration).isEqualTo(expectedDuration) + } + + @Test + fun `setting a duration of 10 seconds`() { + // when + val result = Duration(10, SECONDS) + + // then + assertThat(result.duration).isEqualTo(10) + assertThat(result.toString()).isEqualTo("10 seconds") + } + + @Test + fun `duration of a minute is equal to a duration of 60 seconds`() { + // given + val durationInSeconds = Duration(60, SECONDS) + + // when + val result = Duration(1, MINUTES) + + // then + assertThat(result).isEqualTo(durationInSeconds) + assertThat(result.duration).isEqualTo(60) + assertThat(result.toString()).isEqualTo("60 seconds") + } + + @Test + fun `duration of an hour is equal to a duration of 60 minutes`() { + // given + val durationInMinutes = Duration(60, MINUTES) + + // when + val result = Duration(1, HOURS) + + // then + assertThat(result).isEqualTo(durationInMinutes) + assertThat(result.duration).isEqualTo(3600) + assertThat(result.toString()).isEqualTo("3600 seconds") + } + + @Test + fun `returns false on equals check if other object is not of the same type`() { + // given + val duration = Duration(60, MINUTES) + + // when + val result = duration.equals(EMPTY) + + // then + assertThat(result).isFalse() + } + } + + @Nested + inner class AnimeSeasonTests { + + @Test + fun `default year is 0 indicating unknown and season is undefined`() { + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.animeSeason.year).isZero() + assertThat(result.animeSeason.isYearOfPremiereUnknown()).isTrue() + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Nested + inner class SeasonTests { + + @Test + fun `default season if nothing has been set`() { + // when + val result = AnimeRaw("test") + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + assertThat(result.animeSeason.year).isZero() + } + } + + @Nested + inner class ToStringTests { + + @Test + fun `create formatted string listing all properties`() { + // when + val result = TestAnimeRawObjects.AllPropertiesSet.obj.toString() + + // then + assertThat(result).isEqualTo( + """ + AnimeRaw( + sources = [https://anilist.co/anime/177191] + title = Go-toubun no Hanayome * + type = SPECIAL + episodes = 2 + status = FINISHED + animeSeason = AnimeSeason(season=FALL, year=2024) + picture = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg + thumbnail = https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg + duration = 1440 seconds + scores = [MetaDataProviderScoreValue(hostname=anilist.co, value=75.0, range=1.0..100.0)] + synonyms = [The Quintessential Quintuplets*, 五等分の花嫁*] + studios = [bibury animation studios] + producers = [dax production, nichion, pony canyon] + relatedAnime = [https://anilist.co/anime/131520] + tags = [comedy, drama, ensemble cast, female harem, heterosexual, language barrier, male protagonist, marriage, primarily female cast, romance, school, shounen, twins] + ) + """.trimIndent() + ) + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `default value is 0`() { + // when + val result = AnimeRaw("test") + + // then + assertThat(result.episodes).isZero() + } + + @Test + fun `throws exception if number of episodes is negative`() { + // when + val result = assertThrows<IllegalArgumentException> { + AnimeRaw( + _title = "test", + episodes = -1, + ) + } + + // then + assertThat(result).hasMessage("Episodes cannot have a negative value.") + } + + @Test + fun `doesn't throw an exception if number of episodes is negative and activateChecks is false`() { + // when + val result = AnimeRaw( + _title = "test", + episodes = -1, + activateChecks = false, + ) + + // then + assertThat(result.episodes).isEqualTo(-1) + } + } + + @Nested + inner class PerformChecksTests { + + @Nested + inner class TitleTests { + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) + fun `fixes title`(value: String) { + // given + val obj = AnimeRaw( + _title = value, + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "Death Note", + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + } + + @Nested + inner class EpisodeTests { + + @Test + fun `throws exception if number of episodes is negative`() { + val anime = AnimeRaw( + _title = "test", + episodes = -1, + activateChecks = false, + ) + + // when + val result = assertThrows<IllegalArgumentException> { + anime.performChecks() + } + + // then + assertThat(result).hasMessage("Episodes cannot have a negative value.") + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `removes sources from relatedAnime`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/6351"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2167")), + RelatedAnime(URI("https://myanimelist.net/anime/6351")), + ), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(""" + { + "sources": [ + "https://myanimelist.net/anime/6351" + ], + "title": "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED" + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2167" + } + ], + "tags": [] + } + """.trimIndent()) + } + } + + @Nested + inner class SynonymsTests { + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note "]) + fun `fixes synonyms`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + "Death Note", + ), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", " ", "\u200C"]) + fun `removes blank entries from synonyms`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _synonyms = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _synonyms = HashSet(), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + } + + @Nested + inner class TagsTests { + + @ParameterizedTest + @ValueSource(strings = [" Death Note", "Death Note ", " Death Note ", "DEATH NOTE"]) + fun `fixes tags`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + "death note", + ), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", " ", "\u200C"]) + fun `removes blank entries from tags`(value: String) { + // given + val obj = AnimeRaw( + _title = "デスノート", + _tags = hashSetOf( + value, + ), + activateChecks = false, + ) + + val expected = AnimeRaw( + _title = "デスノート", + _tags = HashSet(), + activateChecks = false, + ) + + // when + obj.performChecks() + + // then + assertThat(obj).isEqualTo(expected) + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `remove related anime if the same uri has been added to sources`() { + // given + val source = URI("https://myanimelist.net/anime/1535") + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + source, + ), + _relatedAnime = hashSetOf( + RelatedAnime(source), + ), + activateChecks = false, + ) + + // when + anime.performChecks() + + // then + assertThat(anime.sources).containsExactly(source) + assertThat(anime.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `default is empty`() { + // when + val result = AnimeRaw("Death Note") + + // then + assertThat(result.scores).isEmpty() + } + + @Test + fun `ensure that you cannot directly modify the internal hashset`() { + // given + val anime = AnimeRaw( + _title = "test", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ), + ) + + // when + anime.scores.clear() + + // then + assertThat(anime.scores).isNotEmpty() + } + + @Nested + inner class AddTagsTests { + + @Test + fun `ignores NoMetaDataProviderScore`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + val result = anime.addScores( + NoMetaDataProviderScore, + ) + + // then + assertThat(result.scores).isEmpty() + } + + @Test + fun `correctly adds score`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + score, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + + @Test + fun `correctly adds multiple scores`() { + // given + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val scoreB = MetaDataProviderScoreValue( + hostname = "other.com", + value = 6.83, + range = 1.0..10.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + scoreA, + scoreB, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreA, + scoreB, + ) + } + + @Test + fun `overrides existing score if the hostname is identical`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 2.54, + range = 1.0..5.0, + ), + ) + + // when + val result = anime.addScores( + score, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + } + + @Nested + inner class AddTagsVarargTests { + + @Test + fun `ignores NoMetaDataProviderScore`() { + // given + val anime = AnimeRaw( + _title = "Death Note", + ) + + // when + val result = anime.addScores( + listOf( + NoMetaDataProviderScore, + ) + ) + + // then + assertThat(result.scores).isEmpty() + } + + @Test + fun `correctly adds score`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + listOf( + score, + ) + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + + @Test + fun `correctly adds multiple scores`() { + // given + val scoreA = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val scoreB = MetaDataProviderScoreValue( + hostname = "other.com", + value = 6.83, + range = 1.0..10.0, + ) + val anime = AnimeRaw( + _title = "test", + ) + + // when + val result = anime.addScores( + listOf( + scoreA, + scoreB, + ) + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + scoreA, + scoreB, + ) + } + + @Test + fun `overrides existing score if the hostname is identical`() { + // given + val score = MetaDataProviderScoreValue( + hostname = "example.org", + value = 4.97, + range = 1.0..5.0, + ) + val anime = AnimeRaw( + _title = "test", + ).addScores( + listOf( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 2.54, + range = 1.0..5.0, + ), + ) + ) + + // when + val result = anime.addScores( + score, + ) + + // then + assertThat(result.scores).containsExactlyInAnyOrder( + score, + ) + } + } + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt index 51747534..58c2aede 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/extractor/DescendantTest.kt @@ -1,508 +1,508 @@ -package io.github.manamiproject.modb.core.extractor - -import io.github.manamiproject.modb.core.extensions.EMPTY -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource - -internal class DescendantTest { - - @Nested - inner class TerminatingChildTests { - - @ParameterizedTest - @ValueSource(strings = ["td", "//td", "/td"]) - fun `element without terminating child`(input: String) { - // given - val obj = Descendant(input) - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isFalse() - assertThat(isTerminatingChild).isFalse() - assertThat(terminatingChild).isEqualTo(EMPTY) - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) - fun `only contains terminating child`(input: String) { - // given - val obj = Descendant(input) - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isFalse() - assertThat(isTerminatingChild).isTrue() - assertThat(terminatingChild).isEmpty() - assertThat(obj.toJsoup()).isEqualTo(input) - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) - fun `single descendant followed by terminating child`(input: String) { - // given - val obj = Descendant("//div/$input") - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isTrue() - assertThat(isTerminatingChild).isFalse() - assertThat(terminatingChild).isEqualTo(input) - assertThat(obj.toJsoup()).isEqualTo("div") - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) - fun `contains terminating child after additional child and filter`(input: String) { - // given - val obj = Descendant("//div/span[@attr='test']/$input") - - // when - val hasTerminatingChild = obj.hasTerminatingChild() - val isTerminatingChild = obj.isTerminatingChild() - val terminatingChild = obj.terminatingChild - - // when - assertThat(hasTerminatingChild).isTrue() - assertThat(isTerminatingChild).isFalse() - assertThat(terminatingChild).isEqualTo(input) - assertThat(obj.toJsoup()).isEqualTo("div > span[attr=test]") - } - } - - @Nested - inner class ToJsoupTests { - - @ParameterizedTest - @ValueSource(strings = ["td", "//td", "/td"]) - fun `return element name`(input: String) { - // given - val obj = Descendant(input) - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("td") - } - - @ParameterizedTest - @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) - fun `empty if only contains terminating child`(input: String) { - // given - val obj = Descendant(input) - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo(input) - } - - @Nested - inner class FilterElementByAttributeEqualsValue { - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value`(input: String) { - // given - val obj = Descendant("//div[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["@class=", "@class= ", "@class =", "@class = "]) - fun `filter element by attribute equals value works with whitespaces`(input: String) { - // given - val obj = Descendant("//div[$input'test']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=test]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value followed by terminating child`(input: String) { - // given - val obj = Descendant("//div[@class='$input']/@itemprop") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value followed by child`(input: String) { - // given - val obj = Descendant("//div[@class='$input']/span") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class=$input] > span") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value on child`(input: String) { - // given - val obj = Descendant("//div/span[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value multiple appearances`(input: String) { - // given - val obj = Descendant("//div[@itemprop='test']/span[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop=test] > span[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value multiple appearances on same element`(input: String) { - // given - val obj = Descendant("//div/span[@itemprop='test'][@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[itemprop=test][class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value combined with different filter`(input: String) { - // given - val obj = Descendant("//div[contains(@itemprop, 'test')]/span[@class='$input']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop*=test] > span[class=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute equals value combined with different filter on same element`(input: String) { - // given - val obj = Descendant("//div/span[@class='$input'][contains(@itemprop, 'test')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class=$input][itemprop*=test]") - } - } - - @Nested - inner class FilterElementByAttributeContains { - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value`(input: String) { - // given - val obj = Descendant("//div[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["@class,", "@class, ", "@class ,", "@class , "]) - fun `filter element by attribute contains value works with whitespaces`(input: String) { - // given - val obj = Descendant("//div[contains($input'test')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=test]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value followed by terminating child`(input: String) { - // given - val obj = Descendant("//div[contains(@class, '$input')]/@itemprop") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value followed by child`(input: String) { - // given - val obj = Descendant("//div[contains(@class, '$input')]/span") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[class*=$input] > span") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value on child`(input: String) { - // given - val obj = Descendant("//div/span[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value multiple appearances`(input: String) { - // given - val obj = Descendant("//div[contains(@itemprop, 'test')]/span[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop*=test] > span[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value multiple appearances on same element`(input: String) { - // given - val obj = Descendant("//div/span[contains(@itemprop, 'test')][contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[itemprop*=test][class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value combined with different filter`(input: String) { - // given - val obj = Descendant("//div[@itemprop='test']/span[contains(@class, '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop=test] > span[class*=$input]") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by attribute contains value combined with different filter on same element`(input: String) { - // given - val obj = Descendant("//div/span[contains(@class, '$input')][@itemprop='test']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span[class*=$input][itemprop=test]") - } - } - - @Nested - inner class FilterElementByTextContains { - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value`(input: String) { - // given - val obj = Descendant("//div[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["text(),", "text(), ", "text() ,", "text() , "]) - fun `filter element by text contains value works with whitespaces`(input: String) { - // given - val obj = Descendant("//div[contains($input'test')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn(test)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value followed by terminating child`(input: String) { - // given - val obj = Descendant("//div[contains(text(), '$input')]/@itemprop") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value followed by child`(input: String) { - // given - val obj = Descendant("//div[contains(text(), '$input')]/span") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn($input) > span") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value on child`(input: String) { - // given - val obj = Descendant("//div/span[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value multiple appearances`(input: String) { - // given - val obj = Descendant("//div[contains(text(), 'test')]/span[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div:matchesOwn(test) > span:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value combined with different filter`(input: String) { - // given - val obj = Descendant("//div[@itemprop='test']/span[contains(text(), '$input')]") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div[itemprop=test] > span:matchesOwn($input)") - } - - @ParameterizedTest - @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) - fun `filter element by text contains value combined with different filter on same element`(input: String) { - // given - val obj = Descendant("//div/span[contains(text(), '$input')][@itemprop='test']") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span:matchesOwn($input)[itemprop=test]") - } - } - - @Nested - inner class FollowingSiblingTests { - - @ParameterizedTest - @ValueSource(strings = ["*", "td"]) - fun `select follow-sibling`(input: String) { - // given - val obj = Descendant("//div/following-sibling::$input") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div ~ $input") - } - - @ParameterizedTest - @ValueSource(strings = ["*", "td"]) - fun `select follow-sibling select text`(input: String) { - // given - val obj = Descendant("//div/following-sibling::$input/text()") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div ~ $input") - } - - @ParameterizedTest - @ValueSource(strings = ["*", "td"]) - fun `select follow-sibling in between`(input: String) { - // given - val obj = Descendant("//div/span/following-sibling::$input/a/@href") - - // when - val result = obj.toJsoup() - - // when - assertThat(result).isEqualTo("div > span ~ $input > a") - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.extractor + +import io.github.manamiproject.modb.core.extensions.EMPTY +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +internal class DescendantTest { + + @Nested + inner class TerminatingChildTests { + + @ParameterizedTest + @ValueSource(strings = ["td", "//td", "/td"]) + fun `element without terminating child`(input: String) { + // given + val obj = Descendant(input) + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isFalse() + assertThat(isTerminatingChild).isFalse() + assertThat(terminatingChild).isEqualTo(EMPTY) + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `only contains terminating child`(input: String) { + // given + val obj = Descendant(input) + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isFalse() + assertThat(isTerminatingChild).isTrue() + assertThat(terminatingChild).isEmpty() + assertThat(obj.toJsoup()).isEqualTo(input) + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `single descendant followed by terminating child`(input: String) { + // given + val obj = Descendant("//div/$input") + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isTrue() + assertThat(isTerminatingChild).isFalse() + assertThat(terminatingChild).isEqualTo(input) + assertThat(obj.toJsoup()).isEqualTo("div") + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `contains terminating child after additional child and filter`(input: String) { + // given + val obj = Descendant("//div/span[@attr='test']/$input") + + // when + val hasTerminatingChild = obj.hasTerminatingChild() + val isTerminatingChild = obj.isTerminatingChild() + val terminatingChild = obj.terminatingChild + + // when + assertThat(hasTerminatingChild).isTrue() + assertThat(isTerminatingChild).isFalse() + assertThat(terminatingChild).isEqualTo(input) + assertThat(obj.toJsoup()).isEqualTo("div > span[attr=test]") + } + } + + @Nested + inner class ToJsoupTests { + + @ParameterizedTest + @ValueSource(strings = ["td", "//td", "/td"]) + fun `return element name`(input: String) { + // given + val obj = Descendant(input) + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("td") + } + + @ParameterizedTest + @ValueSource(strings = ["@attr", "text()", "node()", "html()"]) + fun `empty if only contains terminating child`(input: String) { + // given + val obj = Descendant(input) + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo(input) + } + + @Nested + inner class FilterElementByAttributeEqualsValue { + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value`(input: String) { + // given + val obj = Descendant("//div[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["@class=", "@class= ", "@class =", "@class = "]) + fun `filter element by attribute equals value works with whitespaces`(input: String) { + // given + val obj = Descendant("//div[$input'test']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=test]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value followed by terminating child`(input: String) { + // given + val obj = Descendant("//div[@class='$input']/@itemprop") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value followed by child`(input: String) { + // given + val obj = Descendant("//div[@class='$input']/span") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class=$input] > span") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value on child`(input: String) { + // given + val obj = Descendant("//div/span[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value multiple appearances`(input: String) { + // given + val obj = Descendant("//div[@itemprop='test']/span[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop=test] > span[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value multiple appearances on same element`(input: String) { + // given + val obj = Descendant("//div/span[@itemprop='test'][@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[itemprop=test][class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value combined with different filter`(input: String) { + // given + val obj = Descendant("//div[contains(@itemprop, 'test')]/span[@class='$input']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop*=test] > span[class=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute equals value combined with different filter on same element`(input: String) { + // given + val obj = Descendant("//div/span[@class='$input'][contains(@itemprop, 'test')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class=$input][itemprop*=test]") + } + } + + @Nested + inner class FilterElementByAttributeContains { + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value`(input: String) { + // given + val obj = Descendant("//div[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["@class,", "@class, ", "@class ,", "@class , "]) + fun `filter element by attribute contains value works with whitespaces`(input: String) { + // given + val obj = Descendant("//div[contains($input'test')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=test]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value followed by terminating child`(input: String) { + // given + val obj = Descendant("//div[contains(@class, '$input')]/@itemprop") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value followed by child`(input: String) { + // given + val obj = Descendant("//div[contains(@class, '$input')]/span") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[class*=$input] > span") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value on child`(input: String) { + // given + val obj = Descendant("//div/span[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value multiple appearances`(input: String) { + // given + val obj = Descendant("//div[contains(@itemprop, 'test')]/span[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop*=test] > span[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value multiple appearances on same element`(input: String) { + // given + val obj = Descendant("//div/span[contains(@itemprop, 'test')][contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[itemprop*=test][class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value combined with different filter`(input: String) { + // given + val obj = Descendant("//div[@itemprop='test']/span[contains(@class, '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop=test] > span[class*=$input]") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by attribute contains value combined with different filter on same element`(input: String) { + // given + val obj = Descendant("//div/span[contains(@class, '$input')][@itemprop='test']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span[class*=$input][itemprop=test]") + } + } + + @Nested + inner class FilterElementByTextContains { + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value`(input: String) { + // given + val obj = Descendant("//div[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["text(),", "text(), ", "text() ,", "text() , "]) + fun `filter element by text contains value works with whitespaces`(input: String) { + // given + val obj = Descendant("//div[contains($input'test')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn(test)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value followed by terminating child`(input: String) { + // given + val obj = Descendant("//div[contains(text(), '$input')]/@itemprop") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value followed by child`(input: String) { + // given + val obj = Descendant("//div[contains(text(), '$input')]/span") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn($input) > span") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value on child`(input: String) { + // given + val obj = Descendant("//div/span[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value multiple appearances`(input: String) { + // given + val obj = Descendant("//div[contains(text(), 'test')]/span[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div:matchesOwn(test) > span:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value combined with different filter`(input: String) { + // given + val obj = Descendant("//div[@itemprop='test']/span[contains(text(), '$input')]") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div[itemprop=test] > span:matchesOwn($input)") + } + + @ParameterizedTest + @ValueSource(strings = ["test", "og:url", "mb1 dw3", "http://example.com/test"]) + fun `filter element by text contains value combined with different filter on same element`(input: String) { + // given + val obj = Descendant("//div/span[contains(text(), '$input')][@itemprop='test']") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span:matchesOwn($input)[itemprop=test]") + } + } + + @Nested + inner class FollowingSiblingTests { + + @ParameterizedTest + @ValueSource(strings = ["*", "td"]) + fun `select follow-sibling`(input: String) { + // given + val obj = Descendant("//div/following-sibling::$input") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div ~ $input") + } + + @ParameterizedTest + @ValueSource(strings = ["*", "td"]) + fun `select follow-sibling select text`(input: String) { + // given + val obj = Descendant("//div/following-sibling::$input/text()") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div ~ $input") + } + + @ParameterizedTest + @ValueSource(strings = ["*", "td"]) + fun `select follow-sibling in between`(input: String) { + // given + val obj = Descendant("//div/span/following-sibling::$input/a/@href") + + // when + val result = obj.toJsoup() + + // when + assertThat(result).isEqualTo("div > span ~ $input > a") + } + } + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt index 799f7667..cbe6ab23 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/AnimeRawAdapterTest.kt @@ -1,1799 +1,1799 @@ -package io.github.manamiproject.modb.core.json - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import com.squareup.moshi.JsonDataException -import io.github.manamiproject.modb.core.TestAnimeRawObjects -import io.github.manamiproject.modb.test.exceptionExpected -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal class AnimeRawAdapterTest { - - @Nested - inner class FromJsonTests { - - @Test - fun `correctly deserialize non-null value`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) - } - - @Test - fun `correctly deserialize nullable values`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.obj) - } - - @Test - fun `correctly deserialize object with default values`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) - } - - @Test - fun `throw exception on null value`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson("""null""") - } - - // then - assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$") - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `throws exception if title is blank`(value: String) { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalArgumentException> { - adapter.fromJson(""" - { - "sources": [], - "title": "$value", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Title cannot be blank.") - } - - @Test - fun `throws exception if title is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": null, - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.title") - } - - @Test - fun `throws exception if title is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'title' is either missing or null.") - } - - @Test - fun `throws exception if sources is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": null, - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.sources") - } - - @Test - fun `throws exception if sources is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'sources' is either missing or null.") - } - - @Test - fun `throws exception if type is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": null, - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.type") - } - - @Test - fun `throws exception if type is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'type' is either missing or null.") - } - - @Test - fun `throws exception if episodes is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": null, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected an int but was NULL at path \$.episodes") - } - - @Test - fun `throws exception if episodes is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'episodes' is either missing or null.") - } - - @Test - fun `throws exception if status is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": null, - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.status") - } - - @Test - fun `throws exception if status is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'status' is either missing or null.") - } - - @Test - fun `throws exception if animeSeason is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": null, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$.animeSeason") - } - - @Test - fun `throws exception if animeSeason is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'animeSeason' is either missing or null.") - } - - @Test - fun `throws exception if season in animeSeason is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": null, - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.animeSeason.season") - } - - @Test - fun `throws exception if season in animeSeason is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'season' is either missing or null.") - } - - @Test - fun `throws exception if picture is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": null, - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.picture") - } - - @Test - fun `throws exception if picture is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'picture' is either missing or null.") - } - - @Test - fun `throws exception if thumbnail is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": null, - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.thumbnail") - } - - @Test - fun `throws exception if thumbnail is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'thumbnail' is either missing or null.") - } - - @Test - fun `throws exception if value in duration is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": null, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected an int but was NULL at path \$.duration.value") - } - - @Test - fun `throws exception if value in duration is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'value' is either missing or null.") - } - - @Test - fun `throws exception if unit in duration is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": null - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected a string but was NULL at path \$.duration.unit") - } - - @Test - fun `throws exception if unit in duration is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380 - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'unit' is either missing or null.") - } - - @Test - fun `throws exception if synonyms is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": null, - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.synonyms") - } - - @Test - fun `throws exception if synonyms is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'synonyms' is either missing or null.") - } - - @Test - fun `throws exception if studios is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": null, - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.studios") - } - - @Test - fun `throws exception if studios is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'studios' is either missing or null.") - } - - @Test - fun `throws exception if producers is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": null, - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.producers") - } - - @Test - fun `throws exception if producers is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'producers' is either missing or null.") - } - - @Test - fun `throws exception if relatedAnime is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": null, - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.relatedAnime") - } - - @Test - fun `throws exception if relatedAnime is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "tags": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'relatedAnime' is either missing or null.") - } - - @Test - fun `throws exception if tags is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<JsonDataException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": null - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.tags") - } - - @Test - fun `throws exception if tags is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = exceptionExpected<IllegalStateException> { - adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [] - } - """.trimIndent()) - } - - // then - assertThat(result).hasMessage("Property 'tags' is either missing or null.") - } - - @Test - fun `returns anime with year 0 if year in animeSeason is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.animeSeason.year).isZero() - } - - @Test - fun `returns anime with year 0 if year in animeSeason is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED" - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.animeSeason.year).isZero() - } - - @Test - fun `returns anime with unknown duration if duration property is null`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) - } - - @Test - fun `returns anime with unknown duration if duration property is missing`() { - // given - val adapter = AnimeRawAdapter() - - // when - val result = adapter.fromJson(""" - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 1, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": 2025 - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent())!! - - // then - assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) - } - } - - @Nested - inner class ToJsonTests { - - @Test - fun `correctly serialize non-null value`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `correctly serialize nullable data if serializeNulls has been set`() { - // given - val adapter = AnimeRawAdapter().indent(" ").serializeNulls() - val obj = TestAnimeRawObjects.NullableNotSet.obj - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) - } - - @Test - fun `correctly serialize object with default values`() { - // given - val adapter = AnimeRawAdapter().indent(" ").serializeNulls() - val obj = TestAnimeRawObjects.DefaultAnime.obj - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and fixes title`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _title = " ${value}Go-toubun$value no$value$value Hanayome *$value$value ", - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `runs performChecks if activateChecks is false and throws an exception if episodes is negative`() { - // given - val adapter = AnimeRawAdapter() - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - episodes = -1, - activateChecks = false, - ) - - // when - val result = exceptionExpected<IllegalArgumentException> { - adapter.toJson(obj) - } - - // then - assertThat(result).hasMessage("Episodes cannot have a negative value.") - } - - @Test - fun `runs performChecks if activateChecks is false and removes sources from relatedAnime`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.union(TestAnimeRawObjects.AllPropertiesSet.obj.sources.map { RelatedAnime(it) }).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and fixes synonyms`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union( - hashSetOf(" ${value}The $value${value}Quintessential $value$value${value}Quintuplets*$value$value ") - ).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and removes blank entries from synonyms`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union(hashSetOf(value)).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [" romance", "romance ", "ROMANCE"]) - fun `runs performChecks if activateChecks is false and fixes tags`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @ParameterizedTest - @ValueSource(strings = [ - "", - " ", - "\u00A0", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "\uFEFF", - "\u180E", - "\u2060", - "\u200D", - "\u0090", - "\u200C", - "\u200B", - "\u00AD", - "\u000C", - "\u2028", - "\r", - "\n", - "\t", - ]) - fun `runs performChecks if activateChecks is false and removes blank entries from tags`(value: String) { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), - activateChecks = false, - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `sources, synonyms, relatedAnime and tags are being sorted asc by their string representation`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( - _sources = TestAnimeRawObjects.AllPropertiesSet.obj.sources.toList().shuffled().toHashSet(), - _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.toList().shuffled().toHashSet(), - _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.toList().shuffled().toHashSet(), - _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.toList().shuffled().toHashSet(), - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `sort scores by hostname`() { - // given - val adapter = AnimeRawAdapter().indent(" ") - val obj = TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.copy().addScores( - TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.scores.shuffled(), - ) - - // when - val result = adapter.toJson(obj) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.FullyMergedAllPropertiesSet.serializedPrettyPrint) - } - - @Test - fun `throws exception for a null value`() { - // given - val adapter = AnimeRawAdapter().serializeNulls() - - // when - val result = exceptionExpected<IllegalArgumentException> { - adapter.toJson(null) - } - - // then - assertThat(result).hasMessage("AnimeRawAdapter expects non-nullable value, but received null.") - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import com.squareup.moshi.JsonDataException +import io.github.manamiproject.modb.core.TestAnimeRawObjects +import io.github.manamiproject.modb.test.exceptionExpected +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal class AnimeRawAdapterTest { + + @Nested + inner class FromJsonTests { + + @Test + fun `correctly deserialize non-null value`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) + } + + @Test + fun `correctly deserialize nullable values`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.obj) + } + + @Test + fun `correctly deserialize object with default values`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) + } + + @Test + fun `throw exception on null value`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson("""null""") + } + + // then + assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$") + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `throws exception if title is blank`(value: String) { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalArgumentException> { + adapter.fromJson(""" + { + "sources": [], + "title": "$value", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Title cannot be blank.") + } + + @Test + fun `throws exception if title is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": null, + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.title") + } + + @Test + fun `throws exception if title is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'title' is either missing or null.") + } + + @Test + fun `throws exception if sources is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": null, + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.sources") + } + + @Test + fun `throws exception if sources is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'sources' is either missing or null.") + } + + @Test + fun `throws exception if type is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": null, + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.type") + } + + @Test + fun `throws exception if type is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'type' is either missing or null.") + } + + @Test + fun `throws exception if episodes is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": null, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected an int but was NULL at path \$.episodes") + } + + @Test + fun `throws exception if episodes is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'episodes' is either missing or null.") + } + + @Test + fun `throws exception if status is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": null, + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.status") + } + + @Test + fun `throws exception if status is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'status' is either missing or null.") + } + + @Test + fun `throws exception if animeSeason is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": null, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_OBJECT but was NULL at path \$.animeSeason") + } + + @Test + fun `throws exception if animeSeason is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'animeSeason' is either missing or null.") + } + + @Test + fun `throws exception if season in animeSeason is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": null, + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.animeSeason.season") + } + + @Test + fun `throws exception if season in animeSeason is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'season' is either missing or null.") + } + + @Test + fun `throws exception if picture is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": null, + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.picture") + } + + @Test + fun `throws exception if picture is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'picture' is either missing or null.") + } + + @Test + fun `throws exception if thumbnail is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": null, + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.thumbnail") + } + + @Test + fun `throws exception if thumbnail is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'thumbnail' is either missing or null.") + } + + @Test + fun `throws exception if value in duration is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": null, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected an int but was NULL at path \$.duration.value") + } + + @Test + fun `throws exception if value in duration is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'value' is either missing or null.") + } + + @Test + fun `throws exception if unit in duration is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": null + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected a string but was NULL at path \$.duration.unit") + } + + @Test + fun `throws exception if unit in duration is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380 + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'unit' is either missing or null.") + } + + @Test + fun `throws exception if synonyms is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": null, + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.synonyms") + } + + @Test + fun `throws exception if synonyms is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'synonyms' is either missing or null.") + } + + @Test + fun `throws exception if studios is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": null, + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.studios") + } + + @Test + fun `throws exception if studios is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'studios' is either missing or null.") + } + + @Test + fun `throws exception if producers is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": null, + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.producers") + } + + @Test + fun `throws exception if producers is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'producers' is either missing or null.") + } + + @Test + fun `throws exception if relatedAnime is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": null, + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.relatedAnime") + } + + @Test + fun `throws exception if relatedAnime is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "tags": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'relatedAnime' is either missing or null.") + } + + @Test + fun `throws exception if tags is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<JsonDataException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": null + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Expected BEGIN_ARRAY but was NULL at path \$.tags") + } + + @Test + fun `throws exception if tags is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = exceptionExpected<IllegalStateException> { + adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [] + } + """.trimIndent()) + } + + // then + assertThat(result).hasMessage("Property 'tags' is either missing or null.") + } + + @Test + fun `returns anime with year 0 if year in animeSeason is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.animeSeason.year).isZero() + } + + @Test + fun `returns anime with year 0 if year in animeSeason is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED" + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.animeSeason.year).isZero() + } + + @Test + fun `returns anime with unknown duration if duration property is null`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) + } + + @Test + fun `returns anime with unknown duration if duration property is missing`() { + // given + val adapter = AnimeRawAdapter() + + // when + val result = adapter.fromJson(""" + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 1, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": 2025 + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent())!! + + // then + assertThat(result.duration).isEqualTo(UNKNOWN_DURATION) + } + } + + @Nested + inner class ToJsonTests { + + @Test + fun `correctly serialize non-null value`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `correctly serialize nullable data if serializeNulls has been set`() { + // given + val adapter = AnimeRawAdapter().indent(" ").serializeNulls() + val obj = TestAnimeRawObjects.NullableNotSet.obj + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.NullableNotSet.serializedPrettyPrint) + } + + @Test + fun `correctly serialize object with default values`() { + // given + val adapter = AnimeRawAdapter().indent(" ").serializeNulls() + val obj = TestAnimeRawObjects.DefaultAnime.obj + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and fixes title`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _title = " ${value}Go-toubun$value no$value$value Hanayome *$value$value ", + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `runs performChecks if activateChecks is false and throws an exception if episodes is negative`() { + // given + val adapter = AnimeRawAdapter() + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + episodes = -1, + activateChecks = false, + ) + + // when + val result = exceptionExpected<IllegalArgumentException> { + adapter.toJson(obj) + } + + // then + assertThat(result).hasMessage("Episodes cannot have a negative value.") + } + + @Test + fun `runs performChecks if activateChecks is false and removes sources from relatedAnime`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.union(TestAnimeRawObjects.AllPropertiesSet.obj.sources.map { RelatedAnime(it) }).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and fixes synonyms`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union( + hashSetOf(" ${value}The $value${value}Quintessential $value$value${value}Quintuplets*$value$value ") + ).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and removes blank entries from synonyms`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.union(hashSetOf(value)).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [" romance", "romance ", "ROMANCE"]) + fun `runs performChecks if activateChecks is false and fixes tags`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @ParameterizedTest + @ValueSource(strings = [ + "", + " ", + "\u00A0", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\uFEFF", + "\u180E", + "\u2060", + "\u200D", + "\u0090", + "\u200C", + "\u200B", + "\u00AD", + "\u000C", + "\u2028", + "\r", + "\n", + "\t", + ]) + fun `runs performChecks if activateChecks is false and removes blank entries from tags`(value: String) { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.union(hashSetOf(value)).toHashSet(), + activateChecks = false, + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `sources, synonyms, relatedAnime and tags are being sorted asc by their string representation`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.AllPropertiesSet.obj.copy( + _sources = TestAnimeRawObjects.AllPropertiesSet.obj.sources.toList().shuffled().toHashSet(), + _relatedAnime = TestAnimeRawObjects.AllPropertiesSet.obj.relatedAnime.toList().shuffled().toHashSet(), + _synonyms = TestAnimeRawObjects.AllPropertiesSet.obj.synonyms.toList().shuffled().toHashSet(), + _tags = TestAnimeRawObjects.AllPropertiesSet.obj.tags.toList().shuffled().toHashSet(), + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `sort scores by hostname`() { + // given + val adapter = AnimeRawAdapter().indent(" ") + val obj = TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.copy().addScores( + TestAnimeRawObjects.FullyMergedAllPropertiesSet.obj.scores.shuffled(), + ) + + // when + val result = adapter.toJson(obj) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.FullyMergedAllPropertiesSet.serializedPrettyPrint) + } + + @Test + fun `throws exception for a null value`() { + // given + val adapter = AnimeRawAdapter().serializeNulls() + + // when + val result = exceptionExpected<IllegalArgumentException> { + adapter.toJson(null) + } + + // then + assertThat(result).hasMessage("AnimeRawAdapter expects non-nullable value, but received null.") + } + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt index a461e7a7..932dec75 100644 --- a/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt +++ b/core/src/test/kotlin/io/github/manamiproject/modb/core/json/JsonKtTest.kt @@ -1,441 +1,441 @@ -package io.github.manamiproject.modb.core.json - -import com.squareup.moshi.JsonDataException -import io.github.manamiproject.modb.core.TestAnimeObjects -import io.github.manamiproject.modb.core.TestAnimeRawObjects -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT -import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.util.* -import kotlin.io.path.inputStream -import kotlin.test.Test - -internal class JsonKtTest { - - @Nested - inner class DeserializationTests { - - @Nested - inner class AnimeRawTests { - - @Test - fun `deserialize AnimeRaw - using InputStream, all properties set`() { - tempDirectory { - // given - val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") - TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) - - val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) - - // when - val result = Json.parseJson<AnimeRaw>(inputStream) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize AnimeRaw - using string, all properties set`() { - runTest { - // given - val json = TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint - - // when - val result = Json.parseJson<AnimeRaw>(json) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize AnimeRaw - default properties`() { - runTest { - // given - val json = TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint - - // when - val result = Json.parseJson<AnimeRaw>(json) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) - } - } - } - - @Nested - inner class AnimeTests { - - @Test - fun `deserialize Anime - using inputstream, all properties set`() { - tempDirectory { - // given - val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") - TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) - - val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) - - // when - val result = Json.parseJson<Anime>(inputStream) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize Anime - using string, all properties set`() { - runTest { - // given - val json = TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint - - // when - val result = Json.parseJson<Anime>(json) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) - } - } - - @Test - fun `deserialize Anime - default properties`() { - runTest { - // given - val json = TestAnimeObjects.DefaultAnime.serializedPrettyPrint - - // when - val result = Json.parseJson<Anime>(json) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.obj) - } - } - } - - @Test - fun `deserialize object - throws exception if a property having a non-nullable type is mapped to null`() { - // given - val json = """ - { - "nullableString": null, - "nonNullableString": null - } - """.trimIndent() - - // when - val result = exceptionExpected<JsonDataException> { - Json.parseJson<NullableTestClass>(json)?.copy() - } - - // then - assertThat(result).hasMessage("Non-null value 'nonNullableString' was null at \$.nonNullableString") - } - - @Test - fun `deserialize an array - non nullable types with default value can contain null`() { - runTest { - // given - val json = """ - { - "nonNullableList": [ - "value1", - null, - "value3" - ] - } - """.trimIndent() - - // when - val result = Json.parseJson<ClassWithList>(json) - - // then - assertThat(result?.nonNullableList).containsNull() - } - } - - @Test - fun `deserialize an array - Although the type of the list is non-nullable and copy is called on a list containing null, no exception is being thrown`() { - runTest { - // given - val json = """ - { - "nonNullableList": [ - "value1", - null, - "value3" - ] - } - """.trimIndent() - - // when - val result = Json.parseJson<ClassWithList>(json)?.copy()!! - - // then - assertThat(result.nonNullableList).containsNull() - } - } - } - - @Nested - inner class SerializationTests { - - @Nested - inner class DefaultOptionsTests { - - @Nested - inner class AnimeRawTests { - - @Test - fun `serialize AnimeRaw - all properties set`() { - runTest { - // given - val anime = TestAnimeRawObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) - } - } - - @Test - fun `serialize AnimeRaw - default properties`() { - runTest { - // given - val anime = TestAnimeRawObjects.DefaultAnime.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) - } - } - } - - @Nested - inner class AnimeTests { - - @Test - fun `serialize Anime - all properties set`() { - runTest { - // given - val anime = TestAnimeObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint) - } - } - - @Test - fun `serialize Anime - default properties`() { - runTest { - // given - val anime = TestAnimeObjects.DefaultAnime.obj - - // when - val result = Json.toJson(anime) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.serializedPrettyPrint) - } - } - } - - @Test - fun `serialize - option serialize null is activated by default`() { - runTest { - // given - val expectedJson = """ - { - "nullableString": null, - "nonNullableString": "test" - } - """.trimIndent() - - // when - val result = Json.toJson(NullableTestClass()) - - // then - assertThat(result).isEqualTo(expectedJson) - } - } - } - - @Nested - inner class CustomOptionsTests { - - @Nested - inner class AnimeRawTests { - - @Test - fun `serialize AnimeRaw - deactivate pretty print`() { - runTest { - // given - val anime = TestAnimeRawObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) - - // then - assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedMinified) - } - } - - @Test - fun `serialize AnimeRaw - deactivate serialize null`() { - runTest { - // given - val anime = TestAnimeRawObjects.NullableNotSet.obj - - val expectedJson = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL" - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - // when - val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) - - // then - assertThat(result).isEqualTo(expectedJson) - } - } - } - - @Nested - inner class AnimeTests { - - @Test - fun `serialize Anime - deactivate pretty print`() { - runTest { - // given - val anime = TestAnimeObjects.AllPropertiesSet.obj - - // when - val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) - - // then - assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedMinified) - } - } - - @Test - fun `serialize Anime - deactivate serialize null`() { - runTest { - // given - val anime = TestAnimeObjects.NullableNotSet.obj - - val expectedJson = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL" - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - // when - val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) - - // then - assertThat(result).isEqualTo(expectedJson) - } - } - } - } - } -} - -private data class NullableTestClass(val nullableString: String? = null, val nonNullableString: String = "test") -private data class ClassWithList(val nonNullableList: List<String> = emptyList()) \ No newline at end of file +package io.github.manamiproject.modb.core.json + +import com.squareup.moshi.JsonDataException +import io.github.manamiproject.modb.core.TestAnimeObjects +import io.github.manamiproject.modb.core.TestAnimeRawObjects +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.core.io.LifecycleAwareInputStream +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_PRETTY_PRINT +import io.github.manamiproject.modb.core.json.Json.SerializationOptions.DEACTIVATE_SERIALIZE_NULL +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.util.* +import kotlin.io.path.inputStream +import kotlin.test.Test + +internal class JsonKtTest { + + @Nested + inner class DeserializationTests { + + @Nested + inner class AnimeRawTests { + + @Test + fun `deserialize AnimeRaw - using InputStream, all properties set`() { + tempDirectory { + // given + val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") + TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) + + val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) + + // when + val result = Json.parseJson<AnimeRaw>(inputStream) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize AnimeRaw - using string, all properties set`() { + runTest { + // given + val json = TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint + + // when + val result = Json.parseJson<AnimeRaw>(json) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize AnimeRaw - default properties`() { + runTest { + // given + val json = TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint + + // when + val result = Json.parseJson<AnimeRaw>(json) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.obj) + } + } + } + + @Nested + inner class AnimeTests { + + @Test + fun `deserialize Anime - using inputstream, all properties set`() { + tempDirectory { + // given + val tempFile = tempDir.resolve("${UUID.randomUUID()}.json") + TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint.writeToFile(tempFile) + + val inputStream = LifecycleAwareInputStream(tempFile.inputStream()) + + // when + val result = Json.parseJson<Anime>(inputStream) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize Anime - using string, all properties set`() { + runTest { + // given + val json = TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint + + // when + val result = Json.parseJson<Anime>(json) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.obj) + } + } + + @Test + fun `deserialize Anime - default properties`() { + runTest { + // given + val json = TestAnimeObjects.DefaultAnime.serializedPrettyPrint + + // when + val result = Json.parseJson<Anime>(json) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.obj) + } + } + } + + @Test + fun `deserialize object - throws exception if a property having a non-nullable type is mapped to null`() { + // given + val json = """ + { + "nullableString": null, + "nonNullableString": null + } + """.trimIndent() + + // when + val result = exceptionExpected<JsonDataException> { + Json.parseJson<NullableTestClass>(json)?.copy() + } + + // then + assertThat(result).hasMessage("Non-null value 'nonNullableString' was null at \$.nonNullableString") + } + + @Test + fun `deserialize an array - non nullable types with default value can contain null`() { + runTest { + // given + val json = """ + { + "nonNullableList": [ + "value1", + null, + "value3" + ] + } + """.trimIndent() + + // when + val result = Json.parseJson<ClassWithList>(json) + + // then + assertThat(result?.nonNullableList).containsNull() + } + } + + @Test + fun `deserialize an array - Although the type of the list is non-nullable and copy is called on a list containing null, no exception is being thrown`() { + runTest { + // given + val json = """ + { + "nonNullableList": [ + "value1", + null, + "value3" + ] + } + """.trimIndent() + + // when + val result = Json.parseJson<ClassWithList>(json)?.copy()!! + + // then + assertThat(result.nonNullableList).containsNull() + } + } + } + + @Nested + inner class SerializationTests { + + @Nested + inner class DefaultOptionsTests { + + @Nested + inner class AnimeRawTests { + + @Test + fun `serialize AnimeRaw - all properties set`() { + runTest { + // given + val anime = TestAnimeRawObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedPrettyPrint) + } + } + + @Test + fun `serialize AnimeRaw - default properties`() { + runTest { + // given + val anime = TestAnimeRawObjects.DefaultAnime.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.DefaultAnime.serializedPrettyPrint) + } + } + } + + @Nested + inner class AnimeTests { + + @Test + fun `serialize Anime - all properties set`() { + runTest { + // given + val anime = TestAnimeObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedPrettyPrint) + } + } + + @Test + fun `serialize Anime - default properties`() { + runTest { + // given + val anime = TestAnimeObjects.DefaultAnime.obj + + // when + val result = Json.toJson(anime) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.DefaultAnime.serializedPrettyPrint) + } + } + } + + @Test + fun `serialize - option serialize null is activated by default`() { + runTest { + // given + val expectedJson = """ + { + "nullableString": null, + "nonNullableString": "test" + } + """.trimIndent() + + // when + val result = Json.toJson(NullableTestClass()) + + // then + assertThat(result).isEqualTo(expectedJson) + } + } + } + + @Nested + inner class CustomOptionsTests { + + @Nested + inner class AnimeRawTests { + + @Test + fun `serialize AnimeRaw - deactivate pretty print`() { + runTest { + // given + val anime = TestAnimeRawObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) + + // then + assertThat(result).isEqualTo(TestAnimeRawObjects.AllPropertiesSet.serializedMinified) + } + } + + @Test + fun `serialize AnimeRaw - deactivate serialize null`() { + runTest { + // given + val anime = TestAnimeRawObjects.NullableNotSet.obj + + val expectedJson = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL" + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + // when + val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) + + // then + assertThat(result).isEqualTo(expectedJson) + } + } + } + + @Nested + inner class AnimeTests { + + @Test + fun `serialize Anime - deactivate pretty print`() { + runTest { + // given + val anime = TestAnimeObjects.AllPropertiesSet.obj + + // when + val result = Json.toJson(anime, DEACTIVATE_PRETTY_PRINT) + + // then + assertThat(result).isEqualTo(TestAnimeObjects.AllPropertiesSet.serializedMinified) + } + } + + @Test + fun `serialize Anime - deactivate serialize null`() { + runTest { + // given + val anime = TestAnimeObjects.NullableNotSet.obj + + val expectedJson = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL" + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + // when + val result = Json.toJson(anime, DEACTIVATE_SERIALIZE_NULL) + + // then + assertThat(result).isEqualTo(expectedJson) + } + } + } + } + } +} + +private data class NullableTestClass(val nullableString: String? = null, val nonNullableString: String = "test") +private data class ClassWithList(val nonNullableList: List<String> = emptyList()) \ No newline at end of file diff --git a/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt b/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt index 95c83b96..acba130f 100644 --- a/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt +++ b/kitsu/src/main/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverter.kt @@ -1,247 +1,247 @@ -package io.github.manamiproject.modb.kitsu - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.* -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import kotlinx.coroutines.withContext -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extractor which retrieves the data from raw data. - */ -public class KitsuAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = KitsuConfig, - private val extractor: DataExtractor = JsonDataExtractor, -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract(rawContent, mapOf( - "title" to "$.data[0].attributes.canonicalTitle", - "episodeCount" to "$.data[0].attributes.episodeCount", - "subtype" to "$.data[0].attributes.subtype", - "id" to "$.data[0].id", - "pictureSmall" to "$.data[0].attributes.posterImage.small", - "pictureTiny" to "$.data[0].attributes.posterImage.tiny", - "abbreviatedTitles" to "$.data[0].attributes.abbreviatedTitles", - "titles" to "$.data[0].attributes.titles", - "status" to "$.data[0].attributes.status", - "episodeLength" to "$.data[0].attributes.episodeLength", - "startDate" to "$.data[0].attributes.startDate", - "score" to "$.data[0].attributes.averageRating", - "included" to "$.included" - )) - - return@withContext AnimeRaw( - _title = extractTitle(data), - episodes = extractEpisodes(data), - type = extractType(data), - picture = extractPicture(data), - thumbnail = extractThumbnail(data), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = extractSynonyms(data), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun extractTitle(data: ExtractionResult): Title = data.string("title") - - private fun extractEpisodes(data: ExtractionResult): Episodes = data.intOrDefault("episodeCount") - - private fun extractPicture(data: ExtractionResult): URI { - return if (data.notFound("pictureSmall")) { - NO_PICTURE - } else { - URI(data.string("pictureSmall").trim()) - } - } - - private fun extractThumbnail(data: ExtractionResult): URI { - return if (data.notFound("pictureTiny")) { - NO_PICTURE_THUMBNAIL - } else { - URI(data.string("pictureTiny").trim()) - } - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> = hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) - - private fun extractType(data: ExtractionResult): AnimeType { - return when(data.string("subtype").trim().lowercase()) { - "tv" -> TV - "ona" -> ONA - "movie" -> MOVIE - "ova" -> OVA - "special" -> SPECIAL - "music" -> SPECIAL - else -> throw IllegalStateException("Unknown type [${data.string("subtype")}]") - } - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - return data.listNotNull<Title>("abbreviatedTitles").union( - data.listNotNull<LinkedHashMap<String, Title>>("titles").first().values.filterNotNull()).toHashSet() - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - return included.filter { it["type"] == "mediaRelationships" } - .map { it as Map<*,*> } - .mapNotNull { relationship -> - val destination = ((relationship["relationships"] as Map<*,*>)["destination"] as Map<*,*>)["data"] as Map<*,*> - if (destination["type"] as String != "anime") return@mapNotNull null - val role = (relationship["attributes"] as? Map<*,*>)?.get("role")?.toString() - RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(destination["id"] as AnimeId), role) - } - .toHashSet() - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - if (data.notFound("status")) { - return UNKNOWN_STATUS - } - - return when(data.string("status").trim().lowercase()) { - "finished" -> FINISHED - "current" -> ONGOING - "unreleased" -> UPCOMING - "upcoming" -> UPCOMING - "tba" -> UNKNOWN_STATUS - else -> throw IllegalStateException("Unknown status [${data.string("status")}]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationInMinutes = data.intOrDefault("episodeLength") - return Duration(durationInMinutes, MINUTES) - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - val genres = included.filter { it["type"] == "genres" } - .map { it as Map<*,*> } - .map { it["attributes"] as Map<*,*> } - .map { it["name"] as Tag } - - val categories = included.filter { it["type"] == "categories" } - .map { it as Map<*,*> } - .map { it["attributes"] as Map<*,*> } - .map { it["title"] as Tag } - - return genres.union(categories).toHashSet() - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val startDate = data.stringOrDefault("startDate", EMPTY) - val month = """-\d{2}-""".toRegex().findAll(startDate).firstOrNull()?.value?.remove("-")?.toInt() ?: 0 - val year = """\d{4}""".toRegex().find(startDate)?.value?.let { if (it.startsWith("0")) "0" else it }?.toInt() ?: 0 - - val season = when(month) { - 12, 1, 2 -> WINTER - 3, 4, 5 -> SPRING - 6, 7, 8 -> SUMMER - 9, 10, 11 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - season = season, - year = year - ) - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = 1.0..100.0, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - val producers = included.filter { it["type"] == "producers" } - .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } - - val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } - .map { it as Map<*,*> } - .filter { (it["attributes"] as Map<*,*>)["role"] as String == "studio" } - .map { it["relationships"] as Map<*,*> } - .map { it["producer"] as Map<*,*> } - .map { it["data"] as Map<*,*> } - .map { it["id"] as String } - - return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - if (data.notFound("included")) { - return hashSetOf() - } - - val included = data.listNotNull<Map<String, Any>>("included") - - val producers = included.filter { it["type"] == "producers" } - .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } - - val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } - .map { it as Map<*,*> } - .filter { (it["attributes"] as Map<*,*>)["role"] as String == "producer" } - .map { it["relationships"] as Map<*,*> } - .map { it["producer"] as Map<*,*> } - .map { it["data"] as Map<*,*> } - .map { it["id"] as String } - - return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() - } - - public companion object { - /** - * Singleton of [KitsuAnimeConverter] - * @since 7.0.0 - */ - public val instance: KitsuAnimeConverter by lazy { KitsuAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.kitsu + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.* +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import kotlinx.coroutines.withContext +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extractor which retrieves the data from raw data. + */ +public class KitsuAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = KitsuConfig, + private val extractor: DataExtractor = JsonDataExtractor, +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract(rawContent, mapOf( + "title" to "$.data[0].attributes.canonicalTitle", + "episodeCount" to "$.data[0].attributes.episodeCount", + "subtype" to "$.data[0].attributes.subtype", + "id" to "$.data[0].id", + "pictureSmall" to "$.data[0].attributes.posterImage.small", + "pictureTiny" to "$.data[0].attributes.posterImage.tiny", + "abbreviatedTitles" to "$.data[0].attributes.abbreviatedTitles", + "titles" to "$.data[0].attributes.titles", + "status" to "$.data[0].attributes.status", + "episodeLength" to "$.data[0].attributes.episodeLength", + "startDate" to "$.data[0].attributes.startDate", + "score" to "$.data[0].attributes.averageRating", + "included" to "$.included" + )) + + return@withContext AnimeRaw( + _title = extractTitle(data), + episodes = extractEpisodes(data), + type = extractType(data), + picture = extractPicture(data), + thumbnail = extractThumbnail(data), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = extractSynonyms(data), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun extractTitle(data: ExtractionResult): Title = data.string("title") + + private fun extractEpisodes(data: ExtractionResult): Episodes = data.intOrDefault("episodeCount") + + private fun extractPicture(data: ExtractionResult): URI { + return if (data.notFound("pictureSmall")) { + NO_PICTURE + } else { + URI(data.string("pictureSmall").trim()) + } + } + + private fun extractThumbnail(data: ExtractionResult): URI { + return if (data.notFound("pictureTiny")) { + NO_PICTURE_THUMBNAIL + } else { + URI(data.string("pictureTiny").trim()) + } + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> = hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("id").trim())) + + private fun extractType(data: ExtractionResult): AnimeType { + return when(data.string("subtype").trim().lowercase()) { + "tv" -> TV + "ona" -> ONA + "movie" -> MOVIE + "ova" -> OVA + "special" -> SPECIAL + "music" -> SPECIAL + else -> throw IllegalStateException("Unknown type [${data.string("subtype")}]") + } + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + return data.listNotNull<Title>("abbreviatedTitles").union( + data.listNotNull<LinkedHashMap<String, Title>>("titles").first().values.filterNotNull()).toHashSet() + } + + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + return included.filter { it["type"] == "mediaRelationships" } + .map { it as Map<*,*> } + .mapNotNull { relationship -> + val destination = ((relationship["relationships"] as Map<*,*>)["destination"] as Map<*,*>)["data"] as Map<*,*> + if (destination["type"] as String != "anime") return@mapNotNull null + val role = (relationship["attributes"] as? Map<*,*>)?.get("role")?.toString() + RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(destination["id"] as AnimeId), role) + } + .toHashSet() + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + if (data.notFound("status")) { + return UNKNOWN_STATUS + } + + return when(data.string("status").trim().lowercase()) { + "finished" -> FINISHED + "current" -> ONGOING + "unreleased" -> UPCOMING + "upcoming" -> UPCOMING + "tba" -> UNKNOWN_STATUS + else -> throw IllegalStateException("Unknown status [${data.string("status")}]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationInMinutes = data.intOrDefault("episodeLength") + return Duration(durationInMinutes, MINUTES) + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + val genres = included.filter { it["type"] == "genres" } + .map { it as Map<*,*> } + .map { it["attributes"] as Map<*,*> } + .map { it["name"] as Tag } + + val categories = included.filter { it["type"] == "categories" } + .map { it as Map<*,*> } + .map { it["attributes"] as Map<*,*> } + .map { it["title"] as Tag } + + return genres.union(categories).toHashSet() + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val startDate = data.stringOrDefault("startDate", EMPTY) + val month = """-\d{2}-""".toRegex().findAll(startDate).firstOrNull()?.value?.remove("-")?.toInt() ?: 0 + val year = """\d{4}""".toRegex().find(startDate)?.value?.let { if (it.startsWith("0")) "0" else it }?.toInt() ?: 0 + + val season = when(month) { + 12, 1, 2 -> WINTER + 3, 4, 5 -> SPRING + 6, 7, 8 -> SUMMER + 9, 10, 11 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + season = season, + year = year + ) + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = 1.0..100.0, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + val producers = included.filter { it["type"] == "producers" } + .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } + + val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } + .map { it as Map<*,*> } + .filter { (it["attributes"] as Map<*,*>)["role"] as String == "studio" } + .map { it["relationships"] as Map<*,*> } + .map { it["producer"] as Map<*,*> } + .map { it["data"] as Map<*,*> } + .map { it["id"] as String } + + return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + if (data.notFound("included")) { + return hashSetOf() + } + + val included = data.listNotNull<Map<String, Any>>("included") + + val producers = included.filter { it["type"] == "producers" } + .associate { (it["id"] as String) to ((it["attributes"] as Map<*,*>)["name"] as String) } + + val animeProductionsRelationDestinationIds = included.filter { it["type"] == "animeProductions" } + .map { it as Map<*,*> } + .filter { (it["attributes"] as Map<*,*>)["role"] as String == "producer" } + .map { it["relationships"] as Map<*,*> } + .map { it["producer"] as Map<*,*> } + .map { it["data"] as Map<*,*> } + .map { it["id"] as String } + + return animeProductionsRelationDestinationIds.mapNotNull { producers[it] }.toHashSet() + } + + public companion object { + /** + * Singleton of [KitsuAnimeConverter] + * @since 7.0.0 + */ + public val instance: KitsuAnimeConverter by lazy { KitsuAnimeConverter() } + } +} \ No newline at end of file diff --git a/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt b/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt index 86e1054c..8b235708 100644 --- a/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt +++ b/kitsu/src/test/kotlin/io/github/manamiproject/modb/kitsu/KitsuAnimeConverterTest.kt @@ -1,1334 +1,1334 @@ -package io.github.manamiproject.modb.kitsu - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS - -internal class KitsuAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/title/special_chars.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `episodes is null`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isZero() - } - } - - @Test - fun `39 episodes`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/39.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.episodes).isEqualTo(39) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is tv`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/tv.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is special`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/special.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is ona`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ona.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is ova`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ova.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is movie`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/movie.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is music is mapped to special`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/music.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `posterImage is null`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `posterImage is set`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/pictures.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.picture).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/small.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/tiny.jpg")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `all non main titles and the synonyms are combined`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/combine_titles_and_synonyms.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Maho Yome", - "MahoYome", - "Mahou Tsukai no Yome", - "The Ancient Magus' Bride", - "The Magician's Bride", - "まほよめ", - "魔法使いの嫁", - ) - } - } - - @Test - fun `abbreviatedTitles contains null`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/abbreviatedTitles_contains_null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Detective Conan Movie 04: Captured in Her Eyes", - "Detective Conan Movie 4", - "Meitantei Conan: Hitomi no Naka no Ansatsusha", - "瞳の中の暗殺者", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `build correct source link`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/sources/1517.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.sources).containsExactly(URI("https://kitsu.app/anime/1517")) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no adaption, no relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_no_relations.json") - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no adaption, multiple relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://kitsu.app/anime/10761"), "prequel"), - RelatedAnime(URI("https://kitsu.app/anime/12549"), "other"), - RelatedAnime(URI("https://kitsu.app/anime/13562"), "other"), - RelatedAnime(URI("https://kitsu.app/anime/7742"), "other"), - RelatedAnime(URI("https://kitsu.app/anime/7913"), "side_story"), - RelatedAnime(URI("https://kitsu.app/anime/8273"), "sequel"), - ) - } - } - - @Test - fun `one adaption, one relation`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/one_adaption_one_relation.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactly(RelatedAnime(URI("https://kitsu.app/anime/47280"), "other")) - } - } - - @Test - fun `has adaption, multiple relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_multiple_relations.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://kitsu.app/anime/13850"), "alternative_version"), - RelatedAnime(URI("https://kitsu.app/anime/1759"), "side_story"), - RelatedAnime(URI("https://kitsu.app/anime/1921"), "summary"), - RelatedAnime(URI("https://kitsu.app/anime/2634"), "sequel"), - RelatedAnime(URI("https://kitsu.app/anime/3700"), "summary"), - RelatedAnime(URI("https://kitsu.app/anime/42535"), "side_story"), - RelatedAnime(URI("https://kitsu.app/anime/5518"), "other"), - RelatedAnime(URI("https://kitsu.app/anime/6791"), "spinoff"), - RelatedAnime(URI("https://kitsu.app/anime/7627"), "spinoff"), - ) - } - } - - @Test - fun `has adaption, no relations`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'finished' is mapped to 'FINISHED_AIRING'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/finished.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `'current' is mapped to 'ONGOING'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/current.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'unreleased' is mapped to 'NOT_YET_AIRED'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/unreleased.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'upcoming' is mapped to 'NOT_YET_AIRED'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/upcoming.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'tba' is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/tba.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - - @Test - fun `null is mapped to 'UNKNOWN'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.status).isEqualTo(UNKNOWN_STATUS) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `duration is not set and therefore 0`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `kitsu only uses minutes for duration - 0 implies a duration of less than a minute`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/0.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - - @Test - fun `duration of 24 minutes`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/24.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) - } - } - - @Test - fun `duration of 2 hours`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/120.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `multiple tags`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/multiple_tags.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "adventure", - "bounty hunter", - "future", - "gunfights", - "other planet", - "science fiction", - "shipboard", - "space", - "space travel", - "comedy", - "post apocalypse", - "drama", - "detective", - "sci-fi", - ) - } - } - - @Test - fun `no tags`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_tags.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `startDate is null`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - - @Test - fun `year of premiere is 1989`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/year_of_premiere/1989.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.year).isEqualTo(1989) - } - } - - @Test - fun `year has a wrong format`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/invalid_format.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.isYearOfPremiereUnknown()) - assertThat(result.animeSeason.year).isZero() - } - } - } - - @Nested - inner class SeasonTests { - - @Test - fun `season is 'undefined'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'spring'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/spring.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'summer'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/summer.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'fall'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/fall.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'winter'`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/winter.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/score.json")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(7.897272727272727) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - tempDirectory { - // given - val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/no_score.json")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/multiple_studios.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "shin-ei animation", - "studio pierrot", - ) - } - } - - @Test - fun `no studios`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_studios.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/multiple_producers.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "vap", - "konami", - "nippon television network corporation", - "shueisha", - "madhouse", - "ashi productions", - ) - } - } - - @Test - fun `no producers`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_producers.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - - @Test - fun `no property called 'included'`() { - tempDirectory { - // given - val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = KitsuConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() - } - - val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_property_called_included.json") - - val converter = KitsuAnimeConverter( - metaDataProviderConfig = testKitsuConfig, - ) - - // when - val result = converter.convert(testFileContent) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = KitsuAnimeConverter.instance - - // when - val result = KitsuAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(KitsuAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.kitsu + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS + +internal class KitsuAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/title/special_chars.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `episodes is null`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isZero() + } + } + + @Test + fun `39 episodes`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/episodes/39.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.episodes).isEqualTo(39) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is tv`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/tv.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is special`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/special.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is ona`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ona.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is ova`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/ova.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is movie`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/movie.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is music is mapped to special`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/type/music.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `posterImage is null`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `posterImage is set`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/picture_and_thumbnail/pictures.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.picture).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/small.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://media.kitsu.app/anime/poster_images/42006/tiny.jpg")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `all non main titles and the synonyms are combined`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/combine_titles_and_synonyms.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Maho Yome", + "MahoYome", + "Mahou Tsukai no Yome", + "The Ancient Magus' Bride", + "The Magician's Bride", + "まほよめ", + "魔法使いの嫁", + ) + } + } + + @Test + fun `abbreviatedTitles contains null`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/synonyms/abbreviatedTitles_contains_null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Detective Conan Movie 04: Captured in Her Eyes", + "Detective Conan Movie 4", + "Meitantei Conan: Hitomi no Naka no Ansatsusha", + "瞳の中の暗殺者", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `build correct source link`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/sources/1517.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.sources).containsExactly(URI("https://kitsu.app/anime/1517")) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no adaption, no relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_no_relations.json") + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no adaption, multiple relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_adaption_multiple_relations.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://kitsu.app/anime/10761"), "prequel"), + RelatedAnime(URI("https://kitsu.app/anime/12549"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/13562"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/7742"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/7913"), "side_story"), + RelatedAnime(URI("https://kitsu.app/anime/8273"), "sequel"), + ) + } + } + + @Test + fun `one adaption, one relation`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/one_adaption_one_relation.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactly(RelatedAnime(URI("https://kitsu.app/anime/47280"), "other")) + } + } + + @Test + fun `has adaption, multiple relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_multiple_relations.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://kitsu.app/anime/13850"), "alternative_version"), + RelatedAnime(URI("https://kitsu.app/anime/1759"), "side_story"), + RelatedAnime(URI("https://kitsu.app/anime/1921"), "summary"), + RelatedAnime(URI("https://kitsu.app/anime/2634"), "sequel"), + RelatedAnime(URI("https://kitsu.app/anime/3700"), "summary"), + RelatedAnime(URI("https://kitsu.app/anime/42535"), "side_story"), + RelatedAnime(URI("https://kitsu.app/anime/5518"), "other"), + RelatedAnime(URI("https://kitsu.app/anime/6791"), "spinoff"), + RelatedAnime(URI("https://kitsu.app/anime/7627"), "spinoff"), + ) + } + } + + @Test + fun `has adaption, no relations`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/has_adaption_but_no_relation.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/related_anime/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'finished' is mapped to 'FINISHED_AIRING'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/finished.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `'current' is mapped to 'ONGOING'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/current.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'unreleased' is mapped to 'NOT_YET_AIRED'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/unreleased.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'upcoming' is mapped to 'NOT_YET_AIRED'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/upcoming.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'tba' is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/tba.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + + @Test + fun `null is mapped to 'UNKNOWN'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/status/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.status).isEqualTo(UNKNOWN_STATUS) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `duration is not set and therefore 0`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `kitsu only uses minutes for duration - 0 implies a duration of less than a minute`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/0.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + + @Test + fun `duration of 24 minutes`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/24.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(24, MINUTES)) + } + } + + @Test + fun `duration of 2 hours`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/duration/120.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `multiple tags`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/multiple_tags.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "adventure", + "bounty hunter", + "future", + "gunfights", + "other planet", + "science fiction", + "shipboard", + "space", + "space travel", + "comedy", + "post apocalypse", + "drama", + "detective", + "sci-fi", + ) + } + } + + @Test + fun `no tags`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_tags.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/tags/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `startDate is null`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + + @Test + fun `year of premiere is 1989`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/year_of_premiere/1989.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.year).isEqualTo(1989) + } + } + + @Test + fun `year has a wrong format`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/invalid_format.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.isYearOfPremiereUnknown()) + assertThat(result.animeSeason.year).isZero() + } + } + } + + @Nested + inner class SeasonTests { + + @Test + fun `season is 'undefined'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/null.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'spring'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/spring.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'summer'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/summer.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'fall'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/fall.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'winter'`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/anime_season/season/winter.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/score.json")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(7.897272727272727) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + tempDirectory { + // given + val testKitsuConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(loadTestResource("KitsuAnimeConverterTest/scores/no_score.json")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/multiple_studios.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "shin-ei animation", + "studio pierrot", + ) + } + } + + @Test + fun `no studios`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_studios.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/studios/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/multiple_producers.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "vap", + "konami", + "nippon television network corporation", + "shueisha", + "madhouse", + "ashi productions", + ) + } + } + + @Test + fun `no producers`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_producers.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + + @Test + fun `no property called 'included'`() { + tempDirectory { + // given + val testKitsuConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = KitsuConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = KitsuConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = KitsuConfig.fileSuffix() + } + + val testFileContent = loadTestResource<String>("KitsuAnimeConverterTest/producers/no_property_called_included.json") + + val converter = KitsuAnimeConverter( + metaDataProviderConfig = testKitsuConfig, + ) + + // when + val result = converter.convert(testFileContent) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = KitsuAnimeConverter.instance + + // when + val result = KitsuAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(KitsuAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt b/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt index 7c061605..43c7f3cd 100644 --- a/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt +++ b/lib/src/main/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdder.kt @@ -1,22 +1,22 @@ -package io.github.manamiproject.modb.app.dataset - -import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.simkl.SimklConfig - -object AnimeCountdownUrlAdder { - - fun addAnimeCountdown(list: List<AnimeRaw>): List<AnimeRaw> { - return list.map { anime -> - val sources = anime.sources.filter { source -> source.host == SimklConfig.hostname() } - .map { SimklConfig.extractAnimeId(it) } - .map { AnimeCountdownConfig.buildAnimeLink(it) } - val relatedAnime = anime.relatedAnime.filter { related -> related.uri.host == SimklConfig.hostname() } - .map { related -> RelatedAnime(AnimeCountdownConfig.buildAnimeLink(SimklConfig.extractAnimeId(related.uri)), related.relation) } - - anime.addSources(sources) - anime.addRelatedAnime(relatedAnime) - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.dataset + +import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.simkl.SimklConfig + +object AnimeCountdownUrlAdder { + + fun addAnimeCountdown(list: List<AnimeRaw>): List<AnimeRaw> { + return list.map { anime -> + val sources = anime.sources.filter { source -> source.host == SimklConfig.hostname() } + .map { SimklConfig.extractAnimeId(it) } + .map { AnimeCountdownConfig.buildAnimeLink(it) } + val relatedAnime = anime.relatedAnime.filter { related -> related.uri.host == SimklConfig.hostname() } + .map { related -> RelatedAnime(AnimeCountdownConfig.buildAnimeLink(SimklConfig.extractAnimeId(related.uri)), related.relation) } + + anime.addSources(sources) + anime.addRelatedAnime(relatedAnime) + } + } +} \ No newline at end of file diff --git a/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt b/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt index a2e85d2f..57d8cc0d 100644 --- a/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt +++ b/lib/src/main/kotlin/io/github/manamiproject/modb/app/readme/DefaultReadmeCreator.kt @@ -1,555 +1,555 @@ -package io.github.manamiproject.modb.app.readme - -import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig -import io.github.manamiproject.modb.app.config.AppConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.downloadcontrolstate.DefaultDownloadControlStateAccessor -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor -import io.github.manamiproject.modb.core.date.weekOfYear -import io.github.manamiproject.modb.app.merging.DefaultReviewedIsolatedEntriesAccessor -import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor -import io.github.manamiproject.modb.app.merging.lock.DefaultMergeLockAccessor -import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.extensions.readFile -import io.github.manamiproject.modb.core.extensions.regularFileExists -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.core.logging.LoggerDelegate -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.Year -import io.github.manamiproject.modb.core.config.DatasetRepository -import io.github.manamiproject.modb.simkl.SimklConfig -import java.time.LocalDate - -/** - * Creates the `README.md` for the dataset repo including some statistics and dynamically retrieved content. - * @since 1.0.0 - * @property appConfig Application specific configuration. Uses [AppConfig] by default. - * @property mergeLockAccessor Access to merge locks. - * @property downloadControlStateAccessor Access to DCS files. - * @property reviewedIsolatedEntriesAccessor Access to reviewed isolated entries. - */ -class DefaultReadmeCreator( - private val appConfig: Config = AppConfig.instance, - private val mergeLockAccessor: MergeLockAccessor = DefaultMergeLockAccessor.instance, - private val downloadControlStateAccessor: DownloadControlStateAccessor = DefaultDownloadControlStateAccessor.instance, - private val reviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor = DefaultReviewedIsolatedEntriesAccessor.instance, -): ReadmeCreator { - - override suspend fun updateWith(mergedAnime: List<Anime>) { - val readme = appConfig.outputDirectory().resolve("README.md") - val statistics = createStatisticsBlock(mergedAnime) - - if (!readme.regularFileExists()) { - StringBuilder(createHeaderBlock()) - .append(statistics) - .append(createStructureBlock()) - .toString() - .writeToFile(readme) - return - } - - val currentContent = readme.readFile() - val start = currentContent.indexOf(STATISTICS_START) - val end = currentContent.indexOf(STATISTICS_END) - - if (start == -1 || end == -1 || end < start) { - log.info { "README.md has no statistics block. Leaving it untouched." } - return - } - - // readFile strips a trailing newline, so it is restored here to keep the file unchanged - // apart from the statistics. - val updatedContent = currentContent.substring(0, start + STATISTICS_START.length) + - "\n" + statistics.trim() + "\n" + - currentContent.substring(end) + "\n" - - updatedContent.writeToFile(readme) - } - - private fun createHeaderBlock(): String { - return """ - [![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](${DatasetRepository.license("${getWeekAndYear().second}-${getWeekAndYear().first}")}) - - # anime-offline-database - The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - """.trimIndent() - } - - private suspend fun createStatisticsBlock(mergedAnime: List<Anime>): String { - val weekAndYear = getWeekAndYear() - val numberOfEntriesAndPercentReviewed = getNumberOfEntriesAndPercentReviewed(mergedAnime) - - val block = StringBuilder(""" - - - ## Statistics - Update **week ${weekAndYear.first} [${weekAndYear.second}]** - - The dataset consists of **${numberOfEntriesAndPercentReviewed.first}** entries _(${numberOfEntriesAndPercentReviewed.second}% reviewed)_ composed of: - - | Number of entries | Metadata provider | - |-------------------|-------------------| - - """.trimIndent()) - - appConfig.metaDataProviderConfigurations() - .map { getNumberForMetaDataProvider(it) } - .toMutableList() - .apply { - add(Pair(AnimeCountdownConfig.hostname(), find { it.first == SimklConfig.hostname() }?.second ?: 0)) - } - .sortedByDescending { it.second } - .forEach { - block.append("| ${it.second} | [${it.first}](https://${it.first}) |") - .append("\n") - } - - return block.toString() - } - - private fun createStructureBlock(): String { - return $$""" - - - ## Files - - This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - - | File | Type reference | Description | - |-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| - | `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | - | `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | - | `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | - | `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | - | `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | - | `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | - | `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | - | `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | - | `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - - ## Type reference - - Here is a description of the types in the JSON files. - - ### Database root - - | Field | Type | Nullable | Description | - |--------------|-----------------------------|----------|-------------------------------------------------------------------| - | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | - | `license` | [License](#license) | no | Information about the license of the dataset. | - | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | - | `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | - | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | - | `data` | [Anime[]](#anime) | no | List of all anime. | - - ### License - - | Field | Type | Nullable | Description | - |--------|----------|----------|--------------------------| - | `name` | `String` | no | Name of the license. | - | `url` | `URL` | no | URL to the license file. | - - ### ScoreRange - - | Field | Type | Nullable | Description | - |----------------|----------|----------|----------------------------------------------------------| - | `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | - | `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - - ### Anime - - | Field | Type | Nullable | Description | - |----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| - | `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | - | `title` | `String` | no | Main title. | - | `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | - | `episodes` | `Integer` | no | Number of episodes, movies or parts. | - | `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | - | `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | - | `picture` | `URL` | no | URL of a picture which represents the anime. | - | `thumbnail` | `URL` | no | URL of a smaller version of the picture. | - | `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | - | `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | - | `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | - | `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | - | `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | - | `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | - | `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - - ### RelatedAnime - - | Field | Type | Nullable | Description | - |------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| - | `uri` | `URL` | no | URL to the related anime on the metadata provider. | - | `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | - - ### AnimeSeason - - | Field | Type | Nullable | Description | - |----------|-----------------------------------------------------|----------|---------------------------------------------| - | `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | - | `year` | `Integer` | yes | Year of first release. | - - ### Duration - - | Field | Type | Nullable | Description | - |----------|-----------|----------|-------------------------------------------------------| - | `value` | `Integer` | no | Duration in seconds. | - | `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - - ### Score - - | Field | Type | Nullable | Description | - |---------------------------|----------|----------|--------------------------------------------------------------------------------------| - | `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | - | `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | - | `median` | `Double` | no | Median based on all available scores from all metadata providers. | - - ### DeadEntries root - - | Field | Type | Nullable | Description | - |---------------|-----------------------------|----------|-----------------------------------------------------------------------------| - | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | - | `license` | [License](#license) | no | Information about the license of the dataset. | - | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | - | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | - | `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - - ## Examples - - Here are some examples showing what the files look like. - - ### anime-offline-database.json: - - <details open> - <summary>Example</summary> - - ```json - { - "$schema": "$${DatasetRepository.schema("2025-18", "anime-offline-database.schema.json")}", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "$${DatasetRepository.license("2025-18")}" - }, - "repository": "$${DatasetRepository.url()}", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/8146", - "relation": "Summary" - }, - { - "uri": "https://anidb.net/anime/8147", - "relation": "Summary" - }, - { - "uri": "https://anilist.co/anime/20931" - }, - { - "uri": "https://anilist.co/anime/2994", - "relation": "SUMMARY" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" - }, - { - "uri": "https://animecountdown.com/36687" - }, - { - "uri": "https://animecountdown.com/40690" - }, - { - "uri": "https://anisearch.com/anime/4441" - }, - { - "uri": "https://anisearch.com/anime/5194" - }, - { - "uri": "https://kitsu.app/anime/2707", - "relation": "summary" - }, - { - "uri": "https://livechart.me/anime/3808" - }, - { - "uri": "https://myanimelist.net/anime/2994", - "relation": "Summary" - }, - { - "uri": "https://simkl.com/anime/36687" - }, - { - "uri": "https://simkl.com/anime/40690" - } - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] - } - ``` - </details> - - ### dead-entries/*.json - - <details> - <summary>Example</summary> - - ```json - { - "$schema": "$${DatasetRepository.schema("2025-18", "dead-entries.schema.json")}", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "$${DatasetRepository.license("2025-18")}" - }, - "repository": "$${DatasetRepository.url()}", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] - } - ``` - </details> - """.trimIndent() - } - - private fun getWeekAndYear(): Pair<String, Year> { - val currentDate = LocalDate.now(appConfig.clock()) - val weekOfYear = currentDate.weekOfYear() - val weekWithLeadingZero = if (weekOfYear.week < 10) "0${weekOfYear.week}" else weekOfYear.week.toString() - - return Pair(weekWithLeadingZero, weekOfYear.year) - } - - private suspend fun getNumberForMetaDataProvider(metaDataProviderConfig: MetaDataProviderConfig): Pair<Hostname, Int> { - val numberOfEntries = downloadControlStateAccessor.allAnime(metaDataProviderConfig).count() - return Pair(metaDataProviderConfig.hostname(), numberOfEntries) - } - - private suspend fun getNumberOfEntriesAndPercentReviewed(mergedAnime: List<Anime>): Pair<Int, Int> { - val numberOfUnreviewedEntries = mergedAnime - .map { it.sources } - .map { it.filterNot { uri -> uri.host == AnimeCountdownConfig.hostname() }.toHashSet() } - .map { - return@map if (it.size == 1) { - reviewedIsolatedEntriesAccessor.contains(it.first()) || mergeLockAccessor.hasMergeLock(it) - } else { - mergeLockAccessor.hasMergeLock(it) - } - } - .filterNot { it } - .count() - - val reviewedEntriesInPercent = (100.0 - (numberOfUnreviewedEntries.toDouble() / mergedAnime.size.toDouble() * 100.0)).toInt() - - return Pair(mergedAnime.size, reviewedEntriesInPercent) - } - - companion object { - private val log by LoggerDelegate() - - /** - * Opening marker of the generated statistics section within an existing `README.md`. - * @since 1.0.0 - */ - const val STATISTICS_START: String = "<!-- statistics -->" - - /** - * Closing marker of the generated statistics section within an existing `README.md`. - * @since 1.0.0 - */ - const val STATISTICS_END: String = "<!-- /statistics -->" - - /** - * Singleton of [DefaultReadmeCreator] - * @since 1.0.0 - */ - val instance: DefaultReadmeCreator by lazy { DefaultReadmeCreator() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.readme + +import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig +import io.github.manamiproject.modb.app.config.AppConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.downloadcontrolstate.DefaultDownloadControlStateAccessor +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor +import io.github.manamiproject.modb.core.date.weekOfYear +import io.github.manamiproject.modb.app.merging.DefaultReviewedIsolatedEntriesAccessor +import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor +import io.github.manamiproject.modb.app.merging.lock.DefaultMergeLockAccessor +import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.extensions.readFile +import io.github.manamiproject.modb.core.extensions.regularFileExists +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.core.logging.LoggerDelegate +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.Year +import io.github.manamiproject.modb.core.config.DatasetRepository +import io.github.manamiproject.modb.simkl.SimklConfig +import java.time.LocalDate + +/** + * Creates the `README.md` for the dataset repo including some statistics and dynamically retrieved content. + * @since 1.0.0 + * @property appConfig Application specific configuration. Uses [AppConfig] by default. + * @property mergeLockAccessor Access to merge locks. + * @property downloadControlStateAccessor Access to DCS files. + * @property reviewedIsolatedEntriesAccessor Access to reviewed isolated entries. + */ +class DefaultReadmeCreator( + private val appConfig: Config = AppConfig.instance, + private val mergeLockAccessor: MergeLockAccessor = DefaultMergeLockAccessor.instance, + private val downloadControlStateAccessor: DownloadControlStateAccessor = DefaultDownloadControlStateAccessor.instance, + private val reviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor = DefaultReviewedIsolatedEntriesAccessor.instance, +): ReadmeCreator { + + override suspend fun updateWith(mergedAnime: List<Anime>) { + val readme = appConfig.outputDirectory().resolve("README.md") + val statistics = createStatisticsBlock(mergedAnime) + + if (!readme.regularFileExists()) { + StringBuilder(createHeaderBlock()) + .append(statistics) + .append(createStructureBlock()) + .toString() + .writeToFile(readme) + return + } + + val currentContent = readme.readFile() + val start = currentContent.indexOf(STATISTICS_START) + val end = currentContent.indexOf(STATISTICS_END) + + if (start == -1 || end == -1 || end < start) { + log.info { "README.md has no statistics block. Leaving it untouched." } + return + } + + // readFile strips a trailing newline, so it is restored here to keep the file unchanged + // apart from the statistics. + val updatedContent = currentContent.substring(0, start + STATISTICS_START.length) + + "\n" + statistics.trim() + "\n" + + currentContent.substring(end) + "\n" + + updatedContent.writeToFile(readme) + } + + private fun createHeaderBlock(): String { + return """ + [![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](${DatasetRepository.license("${getWeekAndYear().second}-${getWeekAndYear().first}")}) + + # anime-offline-database + The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + """.trimIndent() + } + + private suspend fun createStatisticsBlock(mergedAnime: List<Anime>): String { + val weekAndYear = getWeekAndYear() + val numberOfEntriesAndPercentReviewed = getNumberOfEntriesAndPercentReviewed(mergedAnime) + + val block = StringBuilder(""" + + + ## Statistics + Update **week ${weekAndYear.first} [${weekAndYear.second}]** + + The dataset consists of **${numberOfEntriesAndPercentReviewed.first}** entries _(${numberOfEntriesAndPercentReviewed.second}% reviewed)_ composed of: + + | Number of entries | Metadata provider | + |-------------------|-------------------| + + """.trimIndent()) + + appConfig.metaDataProviderConfigurations() + .map { getNumberForMetaDataProvider(it) } + .toMutableList() + .apply { + add(Pair(AnimeCountdownConfig.hostname(), find { it.first == SimklConfig.hostname() }?.second ?: 0)) + } + .sortedByDescending { it.second } + .forEach { + block.append("| ${it.second} | [${it.first}](https://${it.first}) |") + .append("\n") + } + + return block.toString() + } + + private fun createStructureBlock(): String { + return $$""" + + + ## Files + + This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + + | File | Type reference | Description | + |-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| + | `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | + | `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | + | `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | + | `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | + | `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | + | `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | + | `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | + | `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | + | `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + + ## Type reference + + Here is a description of the types in the JSON files. + + ### Database root + + | Field | Type | Nullable | Description | + |--------------|-----------------------------|----------|-------------------------------------------------------------------| + | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | + | `license` | [License](#license) | no | Information about the license of the dataset. | + | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | + | `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | + | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | + | `data` | [Anime[]](#anime) | no | List of all anime. | + + ### License + + | Field | Type | Nullable | Description | + |--------|----------|----------|--------------------------| + | `name` | `String` | no | Name of the license. | + | `url` | `URL` | no | URL to the license file. | + + ### ScoreRange + + | Field | Type | Nullable | Description | + |----------------|----------|----------|----------------------------------------------------------| + | `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | + | `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + + ### Anime + + | Field | Type | Nullable | Description | + |----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| + | `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | + | `title` | `String` | no | Main title. | + | `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | + | `episodes` | `Integer` | no | Number of episodes, movies or parts. | + | `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | + | `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | + | `picture` | `URL` | no | URL of a picture which represents the anime. | + | `thumbnail` | `URL` | no | URL of a smaller version of the picture. | + | `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | + | `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | + | `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | + | `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | + | `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | + | `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | + | `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + + ### RelatedAnime + + | Field | Type | Nullable | Description | + |------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| + | `uri` | `URL` | no | URL to the related anime on the metadata provider. | + | `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + + ### AnimeSeason + + | Field | Type | Nullable | Description | + |----------|-----------------------------------------------------|----------|---------------------------------------------| + | `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | + | `year` | `Integer` | yes | Year of first release. | + + ### Duration + + | Field | Type | Nullable | Description | + |----------|-----------|----------|-------------------------------------------------------| + | `value` | `Integer` | no | Duration in seconds. | + | `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + + ### Score + + | Field | Type | Nullable | Description | + |---------------------------|----------|----------|--------------------------------------------------------------------------------------| + | `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | + | `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | + | `median` | `Double` | no | Median based on all available scores from all metadata providers. | + + ### DeadEntries root + + | Field | Type | Nullable | Description | + |---------------|-----------------------------|----------|-----------------------------------------------------------------------------| + | `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | + | `license` | [License](#license) | no | Information about the license of the dataset. | + | `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | + | `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | + | `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + + ## Examples + + Here are some examples showing what the files look like. + + ### anime-offline-database.json: + + <details open> + <summary>Example</summary> + + ```json + { + "$schema": "$${DatasetRepository.schema("2025-18", "anime-offline-database.schema.json")}", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "$${DatasetRepository.license("2025-18")}" + }, + "repository": "$${DatasetRepository.url()}", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] + } + ``` + </details> + + ### dead-entries/*.json + + <details> + <summary>Example</summary> + + ```json + { + "$schema": "$${DatasetRepository.schema("2025-18", "dead-entries.schema.json")}", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "$${DatasetRepository.license("2025-18")}" + }, + "repository": "$${DatasetRepository.url()}", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] + } + ``` + </details> + """.trimIndent() + } + + private fun getWeekAndYear(): Pair<String, Year> { + val currentDate = LocalDate.now(appConfig.clock()) + val weekOfYear = currentDate.weekOfYear() + val weekWithLeadingZero = if (weekOfYear.week < 10) "0${weekOfYear.week}" else weekOfYear.week.toString() + + return Pair(weekWithLeadingZero, weekOfYear.year) + } + + private suspend fun getNumberForMetaDataProvider(metaDataProviderConfig: MetaDataProviderConfig): Pair<Hostname, Int> { + val numberOfEntries = downloadControlStateAccessor.allAnime(metaDataProviderConfig).count() + return Pair(metaDataProviderConfig.hostname(), numberOfEntries) + } + + private suspend fun getNumberOfEntriesAndPercentReviewed(mergedAnime: List<Anime>): Pair<Int, Int> { + val numberOfUnreviewedEntries = mergedAnime + .map { it.sources } + .map { it.filterNot { uri -> uri.host == AnimeCountdownConfig.hostname() }.toHashSet() } + .map { + return@map if (it.size == 1) { + reviewedIsolatedEntriesAccessor.contains(it.first()) || mergeLockAccessor.hasMergeLock(it) + } else { + mergeLockAccessor.hasMergeLock(it) + } + } + .filterNot { it } + .count() + + val reviewedEntriesInPercent = (100.0 - (numberOfUnreviewedEntries.toDouble() / mergedAnime.size.toDouble() * 100.0)).toInt() + + return Pair(mergedAnime.size, reviewedEntriesInPercent) + } + + companion object { + private val log by LoggerDelegate() + + /** + * Opening marker of the generated statistics section within an existing `README.md`. + * @since 1.0.0 + */ + const val STATISTICS_START: String = "<!-- statistics -->" + + /** + * Closing marker of the generated statistics section within an existing `README.md`. + * @since 1.0.0 + */ + const val STATISTICS_END: String = "<!-- /statistics -->" + + /** + * Singleton of [DefaultReadmeCreator] + * @since 1.0.0 + */ + val instance: DefaultReadmeCreator by lazy { DefaultReadmeCreator() } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt index c45e14cd..c06e768a 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/TestingAssets.kt @@ -1,1311 +1,1311 @@ -package io.github.manamiproject.modb.app - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.kommand.CommandExecutor -import io.github.manamiproject.kommand.CommandLineConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.convfiles.AlreadyDownloadedIdsFinder -import io.github.manamiproject.modb.app.crawlers.HighestIdDetector -import io.github.manamiproject.modb.app.crawlers.IdRangeSelector -import io.github.manamiproject.modb.app.crawlers.LastPageMemorizer -import io.github.manamiproject.modb.app.crawlers.PaginationIdRangeSelector -import io.github.manamiproject.modb.app.dataset.DatasetFileAccessor -import io.github.manamiproject.modb.app.dataset.DatasetFileType -import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateEntry -import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateScheduler -import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor -import io.github.manamiproject.modb.app.merging.lock.MergeLock -import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor -import io.github.manamiproject.modb.app.network.NetworkController -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.ConfigRegistry -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.PathAnimeConverter -import io.github.manamiproject.modb.core.downloader.Downloader -import io.github.manamiproject.modb.core.extensions.Directory -import io.github.manamiproject.modb.core.extensions.RegularFile -import io.github.manamiproject.modb.core.httpclient.HttpClient -import io.github.manamiproject.modb.core.httpclient.HttpResponse -import io.github.manamiproject.modb.core.httpclient.RequestBody -import io.github.manamiproject.modb.core.anime.Anime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.anime.MetaDataProviderScoreValue -import io.github.manamiproject.modb.core.anime.NoScore -import io.github.manamiproject.modb.core.anime.ScoreValue -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.OutputKey -import io.github.manamiproject.modb.core.extractor.Selector -import io.github.manamiproject.modb.core.httpclient.RetryCase -import io.github.manamiproject.modb.serde.json.deserializer.Deserializer -import io.github.manamiproject.modb.serde.json.serializer.JsonSerializer -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import kotlinx.coroutines.Deferred -import java.net.URI -import java.net.URL -import java.nio.file.Path -import java.nio.file.WatchEvent -import java.nio.file.WatchKey -import java.nio.file.Watchable -import java.time.Clock -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.util.concurrent.TimeUnit -import java.nio.file.WatchService as JavaWatchService - -internal object TestConfigRegistry: ConfigRegistry { - override fun boolean(key: String): Boolean = shouldNotBeInvoked() - override fun double(key: String): Double = shouldNotBeInvoked() - override fun int(key: String): Int = shouldNotBeInvoked() - override fun <T : Any> list(key: String): List<T> = shouldNotBeInvoked() - override fun localDate(key: String): LocalDate = shouldNotBeInvoked() - override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() - override fun long(key: String): Long = shouldNotBeInvoked() - override fun <T : Any> map(key: String): Map<String, T> = shouldNotBeInvoked() - override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() - override fun string(key: String): String = shouldNotBeInvoked() -} - -internal object TestMetaDataProviderConfig: MetaDataProviderConfig { - override fun hostname() = shouldNotBeInvoked() - override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() - override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() - override fun extractAnimeId(uri: URI): AnimeId = shouldNotBeInvoked() - override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() -} - -internal object TestAppConfig: Config { - override fun isTestContext(): Boolean = true - override fun currentWeekWorkingDir(): Directory = shouldNotBeInvoked() - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() - override fun outputDirectory(): Directory = shouldNotBeInvoked() - override fun downloadControlStateDirectory(): Directory = shouldNotBeInvoked() - override fun downloadsDirectory(): Directory = shouldNotBeInvoked() - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = shouldNotBeInvoked() - override fun clock(): Clock = shouldNotBeInvoked() - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = shouldNotBeInvoked() -} - -internal object TestPathAnimeConverter: PathAnimeConverter { - override suspend fun convert(path: Path): Collection<AnimeRaw> = shouldNotBeInvoked() -} - -internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { - override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() -} - -internal object TestJavaWatchService: JavaWatchService { - override fun close() = shouldNotBeInvoked() - override fun poll(): WatchKey = shouldNotBeInvoked() - override fun poll(timeout: Long, unit: TimeUnit?): WatchKey = shouldNotBeInvoked() - override fun take(): WatchKey = shouldNotBeInvoked() -} - -internal object TestWatchKey: WatchKey { - override fun isValid(): Boolean = shouldNotBeInvoked() - override fun pollEvents(): MutableList<WatchEvent<*>> = shouldNotBeInvoked() - override fun reset(): Boolean = shouldNotBeInvoked() - override fun cancel() = shouldNotBeInvoked() - override fun watchable(): Watchable = shouldNotBeInvoked() -} - -internal object TestDownloadControlStateAccessor: DownloadControlStateAccessor { - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() - override suspend fun allAnime(): List<AnimeRaw> = shouldNotBeInvoked() - override suspend fun allAnime(metaDataProviderConfig: MetaDataProviderConfig): List<AnimeRaw> = shouldNotBeInvoked() - override suspend fun allDcsEntries(): List<DownloadControlStateEntry> = shouldNotBeInvoked() - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = shouldNotBeInvoked() - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = shouldNotBeInvoked() - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry) = shouldNotBeInvoked() - override suspend fun removeDeadEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId) = shouldNotBeInvoked() - override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile = shouldNotBeInvoked() - override suspend fun highestIdAlreadyInDataset(metaDataProviderConfig: MetaDataProviderConfig): Int = shouldNotBeInvoked() -} - -internal object TestDatasetFileAccessor: DatasetFileAccessor { - override suspend fun fetchEntries(): List<Anime> = shouldNotBeInvoked() - override suspend fun saveEntries(anime: List<Anime>) = shouldNotBeInvoked() - override fun offlineDatabaseFile(type: DatasetFileType): RegularFile = shouldNotBeInvoked() -} - -internal object TestMergeLockAccessor: MergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = shouldNotBeInvoked() - override suspend fun isPartOfMergeLock(uri: URI): Boolean = shouldNotBeInvoked() - override suspend fun getMergeLock(uri: URI): MergeLock = shouldNotBeInvoked() - override suspend fun addMergeLock(mergeLock: MergeLock) = shouldNotBeInvoked() - override suspend fun replaceUri(oldUri: URI, newUri: URI) = shouldNotBeInvoked() - override suspend fun removeEntry(uri: URI) = shouldNotBeInvoked() - override suspend fun allSourcesInAllMergeLockEntries(): Set<URI> = shouldNotBeInvoked() -} - -internal class TestJsonSerializer<T>: JsonSerializer<T> { - override suspend fun serialize(obj: T, minify: Boolean): String = shouldNotBeInvoked() -} - -internal object TestDeadEntriesAccessor: DeadEntriesAccessor { - override fun deadEntriesFile(metaDataProviderConfig: MetaDataProviderConfig, type: DatasetFileType): RegularFile = shouldNotBeInvoked() - override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) = shouldNotBeInvoked() - override suspend fun removeDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { } - override suspend fun determineDeadEntries(sources: Collection<URI>): Set<URI> = shouldNotBeInvoked() - override suspend fun fetchDeadEntries(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() -} - -internal object TestReviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor { - override fun contains(uri: URI): Boolean = shouldNotBeInvoked() - override suspend fun addCheckedEntry(uri: URI) = shouldNotBeInvoked() -} - -internal object TestCommandExecutor: CommandExecutor { - override var config: CommandLineConfig - get() = shouldNotBeInvoked() - set(_) = shouldNotBeInvoked() - override fun executeCmd(command: List<String>): String = shouldNotBeInvoked() -} - -internal object TestHttpClient: HttpClient { - override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() -} - -internal object TestNetworkController: NetworkController { - override suspend fun restartAsync(): Deferred<Boolean> = shouldNotBeInvoked() - override fun isNetworkActive(): Boolean = shouldNotBeInvoked() -} - -internal object TestDownloadControlStateScheduler: DownloadControlStateScheduler { - override suspend fun findEntriesNotScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() - override suspend fun findEntriesScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() -} - -internal object TestAlreadyDownloadedIdsFinder: AlreadyDownloadedIdsFinder { - override suspend fun alreadyDownloadedIds(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() -} - -internal object TestDownloader: Downloader { - override suspend fun download(id: AnimeId, onDeadEntry: suspend (AnimeId) -> Unit): String = shouldNotBeInvoked() -} - -internal object TestLastPageMemorizerInt: LastPageMemorizer<Int> { - override suspend fun memorizeLastPage(page: Int) = shouldNotBeInvoked() - override suspend fun retrieveLastPage(): Int = shouldNotBeInvoked() -} - -internal object TestLastPageMemorizerString: LastPageMemorizer<String> { - override suspend fun memorizeLastPage(page: String) = shouldNotBeInvoked() - override suspend fun retrieveLastPage(): String = shouldNotBeInvoked() -} - -internal object TestHighestIdDetector: HighestIdDetector { - override suspend fun detectHighestId(): Int = shouldNotBeInvoked() -} - -internal object TestPaginationIdRangeSelectorInt: PaginationIdRangeSelector<Int> { - override suspend fun idDownloadList(page: Int): List<AnimeId> = shouldNotBeInvoked() -} - -internal object TestPaginationIdRangeSelectorString: PaginationIdRangeSelector<String> { - override suspend fun idDownloadList(page: String): List<AnimeId> = shouldNotBeInvoked() -} - -internal object TestDataExtractor: DataExtractor { - override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() -} - -internal object TestIdRangeSelectorInt: IdRangeSelector<Int> { - override suspend fun idDownloadList(): List<Int> = shouldNotBeInvoked() -} - -@Suppress("unused") -internal object TestAnimeRawObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: AnimeRaw - get() = AnimeRaw("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - _synonyms = hashSetOf( - "DN", - "デスノート", - ), - _studios = hashSetOf( - "madhouse", - ), - _producers = hashSetOf( - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - _tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Go-toubun no Hanayome *", - _sources = hashSetOf( - URI("https://anilist.co/anime/177191"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/131520")), - ), - _tags = hashSetOf( - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anilist.co/anime/177191" - ], - "title": "Go-toubun no Hanayome *", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - } - ], - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://anilist.co/anime/131520" - } - ], - "tags": [ - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "5-toubun no Hanayome*", - _sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/16165")), - RelatedAnime(URI("https://anilist.co/anime/131520")), - RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), - RelatedAnime(URI("https://animecountdown.com/1577789")), - RelatedAnime(URI("https://anisearch.com/anime/16091")), - RelatedAnime(URI("https://kitsu.app/anime/44229")), - RelatedAnime(URI("https://livechart.me/anime/10488")), - RelatedAnime(URI("https://livechart.me/anime/11921")), - RelatedAnime(URI("https://livechart.me/anime/3448")), - RelatedAnime(URI("https://livechart.me/anime/9428")), - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - RelatedAnime(URI("https://simkl.com/anime/1577789")), - ), - _tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anidb.net", - value = 6.63, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "anime-planet.com", - value = 3.845, - range = 0.5..5.0, - ), - MetaDataProviderScoreValue( - hostname = "anisearch.com", - value = 3.9, - range = 0.1..5.0, - ), - MetaDataProviderScoreValue( - hostname = "kitsu.app", - value = 76.95, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "livechart.me", - value = 8.35, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "simkl.com", - value = 7.9, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anidb.net", - "value": 6.63, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "anime-planet.com", - "value": 3.845, - "range": { - "minInclusive": 0.5, - "maxInclusive": 5.0 - } - }, - { - "hostname": "anisearch.com", - "value": 3.9, - "range": { - "minInclusive": 0.1, - "maxInclusive": 5.0 - } - }, - { - "hostname": "kitsu.app", - "value": 76.95, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "livechart.me", - "value": 8.35, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "simkl.com", - "value": 7.9, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/16165" - }, - { - "uri": "https://anilist.co/anime/131520" - }, - { - "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" - }, - { - "uri": "https://animecountdown.com/1577789" - }, - { - "uri": "https://anisearch.com/anime/16091" - }, - { - "uri": "https://kitsu.app/anime/44229" - }, - { - "uri": "https://livechart.me/anime/10488" - }, - { - "uri": "https://livechart.me/anime/11921" - }, - { - "uri": "https://livechart.me/anime/3448" - }, - { - "uri": "https://livechart.me/anime/9428" - }, - { - "uri": "https://myanimelist.net/anime/48548" - }, - { - "uri": "https://simkl.com/anime/1577789" - } - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -@Suppress("unused") -internal object TestAnimeObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: Anime - get() = Anime("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "score": null, - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: Anime - get() = Anime( - title = "Death Note", - sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - score = NoScore, - synonyms = hashSetOf( - "DN", - "デスノート", - ), - studios = hashSetOf( - "madhouse", - ), - producers = hashSetOf( - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "score": null, - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://myanimelist.net/anime/58755"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.44, - arithmeticMean = 7.44, - median = 7.44, - ), - synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - ), - tags = hashSetOf( - "comedy", - "harem", - "romance", - "school", - "shounen", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/58755" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.44, - "arithmeticMean": 7.44, - "median": 7.44 - }, - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/48548" - } - ], - "tags": [ - "comedy", - "harem", - "romance", - "school", - "shounen" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.616175305477198, - arithmeticMean = 7.624601113172541, - median = 7.7272727272727275, - ), - synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - "tbs", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/16165")), - RelatedAnime(URI("https://anilist.co/anime/131520")), - RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), - RelatedAnime(URI("https://animecountdown.com/1577789")), - RelatedAnime(URI("https://anisearch.com/anime/16091")), - RelatedAnime(URI("https://kitsu.app/anime/44229")), - RelatedAnime(URI("https://livechart.me/anime/10488")), - RelatedAnime(URI("https://livechart.me/anime/11921")), - RelatedAnime(URI("https://livechart.me/anime/3448")), - RelatedAnime(URI("https://livechart.me/anime/9428")), - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - RelatedAnime(URI("https://simkl.com/anime/1577789")), - ), - tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ) - - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.616175305477198, - "arithmeticMean": 7.624601113172541, - "median": 7.7272727272727275 - }, - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon", - "tbs" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/16165" - }, - { - "uri": "https://anilist.co/anime/131520" - }, - { - "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" - }, - { - "uri": "https://animecountdown.com/1577789" - }, - { - "uri": "https://anisearch.com/anime/16091" - }, - { - "uri": "https://kitsu.app/anime/44229" - }, - { - "uri": "https://livechart.me/anime/10488" - }, - { - "uri": "https://livechart.me/anime/11921" - }, - { - "uri": "https://livechart.me/anime/3448" - }, - { - "uri": "https://livechart.me/anime/9428" - }, - { - "uri": "https://myanimelist.net/anime/48548" - }, - { - "uri": "https://simkl.com/anime/1577789" - } - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} \ No newline at end of file +package io.github.manamiproject.modb.app + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.kommand.CommandExecutor +import io.github.manamiproject.kommand.CommandLineConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.convfiles.AlreadyDownloadedIdsFinder +import io.github.manamiproject.modb.app.crawlers.HighestIdDetector +import io.github.manamiproject.modb.app.crawlers.IdRangeSelector +import io.github.manamiproject.modb.app.crawlers.LastPageMemorizer +import io.github.manamiproject.modb.app.crawlers.PaginationIdRangeSelector +import io.github.manamiproject.modb.app.dataset.DatasetFileAccessor +import io.github.manamiproject.modb.app.dataset.DatasetFileType +import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateAccessor +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateEntry +import io.github.manamiproject.modb.app.downloadcontrolstate.DownloadControlStateScheduler +import io.github.manamiproject.modb.app.merging.ReviewedIsolatedEntriesAccessor +import io.github.manamiproject.modb.app.merging.lock.MergeLock +import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor +import io.github.manamiproject.modb.app.network.NetworkController +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.ConfigRegistry +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.PathAnimeConverter +import io.github.manamiproject.modb.core.downloader.Downloader +import io.github.manamiproject.modb.core.extensions.Directory +import io.github.manamiproject.modb.core.extensions.RegularFile +import io.github.manamiproject.modb.core.httpclient.HttpClient +import io.github.manamiproject.modb.core.httpclient.HttpResponse +import io.github.manamiproject.modb.core.httpclient.RequestBody +import io.github.manamiproject.modb.core.anime.Anime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.anime.MetaDataProviderScoreValue +import io.github.manamiproject.modb.core.anime.NoScore +import io.github.manamiproject.modb.core.anime.ScoreValue +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.OutputKey +import io.github.manamiproject.modb.core.extractor.Selector +import io.github.manamiproject.modb.core.httpclient.RetryCase +import io.github.manamiproject.modb.serde.json.deserializer.Deserializer +import io.github.manamiproject.modb.serde.json.serializer.JsonSerializer +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import kotlinx.coroutines.Deferred +import java.net.URI +import java.net.URL +import java.nio.file.Path +import java.nio.file.WatchEvent +import java.nio.file.WatchKey +import java.nio.file.Watchable +import java.time.Clock +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.util.concurrent.TimeUnit +import java.nio.file.WatchService as JavaWatchService + +internal object TestConfigRegistry: ConfigRegistry { + override fun boolean(key: String): Boolean = shouldNotBeInvoked() + override fun double(key: String): Double = shouldNotBeInvoked() + override fun int(key: String): Int = shouldNotBeInvoked() + override fun <T : Any> list(key: String): List<T> = shouldNotBeInvoked() + override fun localDate(key: String): LocalDate = shouldNotBeInvoked() + override fun localDateTime(key: String): LocalDateTime = shouldNotBeInvoked() + override fun long(key: String): Long = shouldNotBeInvoked() + override fun <T : Any> map(key: String): Map<String, T> = shouldNotBeInvoked() + override fun offsetDateTime(key: String): OffsetDateTime = shouldNotBeInvoked() + override fun string(key: String): String = shouldNotBeInvoked() +} + +internal object TestMetaDataProviderConfig: MetaDataProviderConfig { + override fun hostname() = shouldNotBeInvoked() + override fun buildAnimeLink(id: AnimeId): URI = shouldNotBeInvoked() + override fun buildDataDownloadLink(id: String): URI = shouldNotBeInvoked() + override fun extractAnimeId(uri: URI): AnimeId = shouldNotBeInvoked() + override fun fileSuffix(): FileSuffix = shouldNotBeInvoked() +} + +internal object TestAppConfig: Config { + override fun isTestContext(): Boolean = true + override fun currentWeekWorkingDir(): Directory = shouldNotBeInvoked() + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() + override fun outputDirectory(): Directory = shouldNotBeInvoked() + override fun downloadControlStateDirectory(): Directory = shouldNotBeInvoked() + override fun downloadsDirectory(): Directory = shouldNotBeInvoked() + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = shouldNotBeInvoked() + override fun clock(): Clock = shouldNotBeInvoked() + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = shouldNotBeInvoked() +} + +internal object TestPathAnimeConverter: PathAnimeConverter { + override suspend fun convert(path: Path): Collection<AnimeRaw> = shouldNotBeInvoked() +} + +internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { + override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() +} + +internal object TestJavaWatchService: JavaWatchService { + override fun close() = shouldNotBeInvoked() + override fun poll(): WatchKey = shouldNotBeInvoked() + override fun poll(timeout: Long, unit: TimeUnit?): WatchKey = shouldNotBeInvoked() + override fun take(): WatchKey = shouldNotBeInvoked() +} + +internal object TestWatchKey: WatchKey { + override fun isValid(): Boolean = shouldNotBeInvoked() + override fun pollEvents(): MutableList<WatchEvent<*>> = shouldNotBeInvoked() + override fun reset(): Boolean = shouldNotBeInvoked() + override fun cancel() = shouldNotBeInvoked() + override fun watchable(): Watchable = shouldNotBeInvoked() +} + +internal object TestDownloadControlStateAccessor: DownloadControlStateAccessor { + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = shouldNotBeInvoked() + override suspend fun allAnime(): List<AnimeRaw> = shouldNotBeInvoked() + override suspend fun allAnime(metaDataProviderConfig: MetaDataProviderConfig): List<AnimeRaw> = shouldNotBeInvoked() + override suspend fun allDcsEntries(): List<DownloadControlStateEntry> = shouldNotBeInvoked() + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = shouldNotBeInvoked() + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = shouldNotBeInvoked() + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry) = shouldNotBeInvoked() + override suspend fun removeDeadEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId) = shouldNotBeInvoked() + override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile = shouldNotBeInvoked() + override suspend fun highestIdAlreadyInDataset(metaDataProviderConfig: MetaDataProviderConfig): Int = shouldNotBeInvoked() +} + +internal object TestDatasetFileAccessor: DatasetFileAccessor { + override suspend fun fetchEntries(): List<Anime> = shouldNotBeInvoked() + override suspend fun saveEntries(anime: List<Anime>) = shouldNotBeInvoked() + override fun offlineDatabaseFile(type: DatasetFileType): RegularFile = shouldNotBeInvoked() +} + +internal object TestMergeLockAccessor: MergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = shouldNotBeInvoked() + override suspend fun isPartOfMergeLock(uri: URI): Boolean = shouldNotBeInvoked() + override suspend fun getMergeLock(uri: URI): MergeLock = shouldNotBeInvoked() + override suspend fun addMergeLock(mergeLock: MergeLock) = shouldNotBeInvoked() + override suspend fun replaceUri(oldUri: URI, newUri: URI) = shouldNotBeInvoked() + override suspend fun removeEntry(uri: URI) = shouldNotBeInvoked() + override suspend fun allSourcesInAllMergeLockEntries(): Set<URI> = shouldNotBeInvoked() +} + +internal class TestJsonSerializer<T>: JsonSerializer<T> { + override suspend fun serialize(obj: T, minify: Boolean): String = shouldNotBeInvoked() +} + +internal object TestDeadEntriesAccessor: DeadEntriesAccessor { + override fun deadEntriesFile(metaDataProviderConfig: MetaDataProviderConfig, type: DatasetFileType): RegularFile = shouldNotBeInvoked() + override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) = shouldNotBeInvoked() + override suspend fun removeDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { } + override suspend fun determineDeadEntries(sources: Collection<URI>): Set<URI> = shouldNotBeInvoked() + override suspend fun fetchDeadEntries(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() +} + +internal object TestReviewedIsolatedEntriesAccessor: ReviewedIsolatedEntriesAccessor { + override fun contains(uri: URI): Boolean = shouldNotBeInvoked() + override suspend fun addCheckedEntry(uri: URI) = shouldNotBeInvoked() +} + +internal object TestCommandExecutor: CommandExecutor { + override var config: CommandLineConfig + get() = shouldNotBeInvoked() + set(_) = shouldNotBeInvoked() + override fun executeCmd(command: List<String>): String = shouldNotBeInvoked() +} + +internal object TestHttpClient: HttpClient { + override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() +} + +internal object TestNetworkController: NetworkController { + override suspend fun restartAsync(): Deferred<Boolean> = shouldNotBeInvoked() + override fun isNetworkActive(): Boolean = shouldNotBeInvoked() +} + +internal object TestDownloadControlStateScheduler: DownloadControlStateScheduler { + override suspend fun findEntriesNotScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() + override suspend fun findEntriesScheduledForCurrentWeek(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() +} + +internal object TestAlreadyDownloadedIdsFinder: AlreadyDownloadedIdsFinder { + override suspend fun alreadyDownloadedIds(metaDataProviderConfig: MetaDataProviderConfig): Set<AnimeId> = shouldNotBeInvoked() +} + +internal object TestDownloader: Downloader { + override suspend fun download(id: AnimeId, onDeadEntry: suspend (AnimeId) -> Unit): String = shouldNotBeInvoked() +} + +internal object TestLastPageMemorizerInt: LastPageMemorizer<Int> { + override suspend fun memorizeLastPage(page: Int) = shouldNotBeInvoked() + override suspend fun retrieveLastPage(): Int = shouldNotBeInvoked() +} + +internal object TestLastPageMemorizerString: LastPageMemorizer<String> { + override suspend fun memorizeLastPage(page: String) = shouldNotBeInvoked() + override suspend fun retrieveLastPage(): String = shouldNotBeInvoked() +} + +internal object TestHighestIdDetector: HighestIdDetector { + override suspend fun detectHighestId(): Int = shouldNotBeInvoked() +} + +internal object TestPaginationIdRangeSelectorInt: PaginationIdRangeSelector<Int> { + override suspend fun idDownloadList(page: Int): List<AnimeId> = shouldNotBeInvoked() +} + +internal object TestPaginationIdRangeSelectorString: PaginationIdRangeSelector<String> { + override suspend fun idDownloadList(page: String): List<AnimeId> = shouldNotBeInvoked() +} + +internal object TestDataExtractor: DataExtractor { + override suspend fun extract(rawContent: String, selection: Map<OutputKey, Selector>): ExtractionResult = shouldNotBeInvoked() +} + +internal object TestIdRangeSelectorInt: IdRangeSelector<Int> { + override suspend fun idDownloadList(): List<Int> = shouldNotBeInvoked() +} + +@Suppress("unused") +internal object TestAnimeRawObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: AnimeRaw + get() = AnimeRaw("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + _synonyms = hashSetOf( + "DN", + "デスノート", + ), + _studios = hashSetOf( + "madhouse", + ), + _producers = hashSetOf( + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + _tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Go-toubun no Hanayome *", + _sources = hashSetOf( + URI("https://anilist.co/anime/177191"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/131520")), + ), + _tags = hashSetOf( + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anilist.co/anime/177191" + ], + "title": "Go-toubun no Hanayome *", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + } + ], + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anilist.co/anime/131520" + } + ], + "tags": [ + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "5-toubun no Hanayome*", + _sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + _tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anidb.net", + value = 6.63, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "anime-planet.com", + value = 3.845, + range = 0.5..5.0, + ), + MetaDataProviderScoreValue( + hostname = "anisearch.com", + value = 3.9, + range = 0.1..5.0, + ), + MetaDataProviderScoreValue( + hostname = "kitsu.app", + value = 76.95, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "livechart.me", + value = 8.35, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "simkl.com", + value = 7.9, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anidb.net", + "value": 6.63, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "anime-planet.com", + "value": 3.845, + "range": { + "minInclusive": 0.5, + "maxInclusive": 5.0 + } + }, + { + "hostname": "anisearch.com", + "value": 3.9, + "range": { + "minInclusive": 0.1, + "maxInclusive": 5.0 + } + }, + { + "hostname": "kitsu.app", + "value": 76.95, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "livechart.me", + "value": 8.35, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "simkl.com", + "value": 7.9, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +@Suppress("unused") +internal object TestAnimeObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: Anime + get() = Anime("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "score": null, + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: Anime + get() = Anime( + title = "Death Note", + sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + score = NoScore, + synonyms = hashSetOf( + "DN", + "デスノート", + ), + studios = hashSetOf( + "madhouse", + ), + producers = hashSetOf( + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "score": null, + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://myanimelist.net/anime/58755"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.44, + arithmeticMean = 7.44, + median = 7.44, + ), + synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + ), + tags = hashSetOf( + "comedy", + "harem", + "romance", + "school", + "shounen", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/58755" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.44, + "arithmeticMean": 7.44, + "median": 7.44 + }, + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/48548" + } + ], + "tags": [ + "comedy", + "harem", + "romance", + "school", + "shounen" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.616175305477198, + arithmeticMean = 7.624601113172541, + median = 7.7272727272727275, + ), + synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + "tbs", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ) + + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.616175305477198, + "arithmeticMean": 7.624601113172541, + "median": 7.7272727272727275 + }, + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon", + "tbs" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt index fbd02833..78c6db5b 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/dataset/AnimeCountdownUrlAdderTest.kt @@ -1,124 +1,124 @@ -package io.github.manamiproject.modb.app.dataset - -import io.github.manamiproject.modb.anilist.AnilistConfig -import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.myanimelist.MyanimelistConfig -import io.github.manamiproject.modb.simkl.SimklConfig -import org.assertj.core.api.Assertions.assertThat -import kotlin.test.Test - -internal class AnimeCountdownUrlAdderTest { - - @Test - fun `correctly adds URI in sources`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf( - SimklConfig.buildAnimeLink("1535"), - ), - ) - - val expectedAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf( - AnimeCountdownConfig.buildAnimeLink("1535"), - SimklConfig.buildAnimeLink("1535"), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(expectedAnime) - } - - @Test - fun `correctly adds URI in relatedAnime`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - RelatedAnime(SimklConfig.buildAnimeLink("1535")), - ), - ) - - val expectedAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - RelatedAnime(AnimeCountdownConfig.buildAnimeLink("1535")), - RelatedAnime(SimklConfig.buildAnimeLink("1535")), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(expectedAnime) - } - - @Test - fun `keeps the relation when adding URI in relatedAnime`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - RelatedAnime(SimklConfig.buildAnimeLink("1535"), "Sequel"), - ), - ) - - val expectedAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - RelatedAnime(AnimeCountdownConfig.buildAnimeLink("1535"), "Sequel"), - RelatedAnime(SimklConfig.buildAnimeLink("1535"), "Sequel"), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(expectedAnime) - } - - @Test - fun `leaves sources property of anime without simkl source unaffected`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf( - MyanimelistConfig.buildAnimeLink("1535"), - AnilistConfig.buildAnimeLink("1535"), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(testAnime) - } - - @Test - fun `leaves relatedAnime property of anime without simkl source unaffected`() { - // given - val testAnime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf( - RelatedAnime(MyanimelistConfig.buildAnimeLink("1535")), - RelatedAnime(AnilistConfig.buildAnimeLink("1535")), - ), - ) - - // when - val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) - - // then - assertThat(result).containsExactly(testAnime) - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.dataset + +import io.github.manamiproject.modb.anilist.AnilistConfig +import io.github.manamiproject.modb.animecountdown.AnimeCountdownConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.myanimelist.MyanimelistConfig +import io.github.manamiproject.modb.simkl.SimklConfig +import org.assertj.core.api.Assertions.assertThat +import kotlin.test.Test + +internal class AnimeCountdownUrlAdderTest { + + @Test + fun `correctly adds URI in sources`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf( + SimklConfig.buildAnimeLink("1535"), + ), + ) + + val expectedAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf( + AnimeCountdownConfig.buildAnimeLink("1535"), + SimklConfig.buildAnimeLink("1535"), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(expectedAnime) + } + + @Test + fun `correctly adds URI in relatedAnime`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(SimklConfig.buildAnimeLink("1535")), + ), + ) + + val expectedAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(AnimeCountdownConfig.buildAnimeLink("1535")), + RelatedAnime(SimklConfig.buildAnimeLink("1535")), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(expectedAnime) + } + + @Test + fun `keeps the relation when adding URI in relatedAnime`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(SimklConfig.buildAnimeLink("1535"), "Sequel"), + ), + ) + + val expectedAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(AnimeCountdownConfig.buildAnimeLink("1535"), "Sequel"), + RelatedAnime(SimklConfig.buildAnimeLink("1535"), "Sequel"), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(expectedAnime) + } + + @Test + fun `leaves sources property of anime without simkl source unaffected`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf( + MyanimelistConfig.buildAnimeLink("1535"), + AnilistConfig.buildAnimeLink("1535"), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(testAnime) + } + + @Test + fun `leaves relatedAnime property of anime without simkl source unaffected`() { + // given + val testAnime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf( + RelatedAnime(MyanimelistConfig.buildAnimeLink("1535")), + RelatedAnime(AnilistConfig.buildAnimeLink("1535")), + ), + ) + + // when + val result = AnimeCountdownUrlAdder.addAnimeCountdown(listOf(testAnime)) + + // then + assertThat(result).containsExactly(testAnime) + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt index ab55bc14..432817fa 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateSchedulerTest.kt @@ -1,323 +1,323 @@ -package io.github.manamiproject.modb.app.downloadcontrolstate - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.anilist.AnilistConfig -import io.github.manamiproject.modb.app.TestAppConfig -import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.date.WeekOfYear -import io.github.manamiproject.modb.kitsu.KitsuConfig -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test - -internal class DefaultDownloadControlStateSchedulerTest { - - @Nested - inner class FindEntriesNotScheduledForCurrentWeekTests { - - @Test - fun `same year`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2021, 45), - _anime = AnimeRaw( - _title = "Fruits Basket", - type = TV, - episodes = 26, - picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), - thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), - status = FINISHED, - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 2001, - ), - _sources = hashSetOf(URI("https://kitsu.app/anime/99")), - _synonyms = hashSetOf( - "Furuba", - "フルーツバスケット", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://kitsu.app/anime/41995")), - ), - ), - ) - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 2), - _nextDownload = WeekOfYear(2021, 4), - _anime = AnimeRaw( - _title = "Unexpected", - _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), - ), - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("99") - } - } - - @Test - fun `next yeart`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2022, 2), - _anime = AnimeRaw( - _title = "Fruits Basket", - type = TV, - episodes = 26, - picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), - thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), - status = FINISHED, - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 2001, - ), - _sources = hashSetOf(URI("https://kitsu.app/anime/99")), - _synonyms = hashSetOf( - "Furuba", - "フルーツバスケット", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://kitsu.app/anime/41995")), - ), - ), - ) - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 2), - _nextDownload = WeekOfYear(2021, 4), - _anime = AnimeRaw( - _title = "Unexpected", - _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), - ), - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("99") - } - } - } - - @Nested - inner class FindEntriesScheduledForCurrentWeekTests { - - @Test - fun `same year`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 2), - _nextDownload = WeekOfYear(2021, 4), - _anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/30")), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - ) - - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2021, 45), - _anime = AnimeRaw( - _title = "Unexpected Entry", - _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), - ) - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("32") - } - } - - @Test - fun `previous year`() { - tempDirectory { - // given - val testAppConfig = object : Config by TestAppConfig { - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) - } - - val expectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2020, 49), - _nextDownload = WeekOfYear(2020, 51), - _anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/30")), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - ) - - val unexpectedEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear(2021, 44), - _nextDownload = WeekOfYear(2021, 45), - _anime = AnimeRaw( - _title = "Unexpected Entry", - _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), - ) - ) - - val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( - expectedEntry, - unexpectedEntry, - ) - } - - val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) - - // then - assertThat(result).containsExactlyInAnyOrder("32") - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultDownloadControlStateScheduler.instance - - // when - val result = DefaultDownloadControlStateScheduler.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateScheduler::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.downloadcontrolstate + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.anilist.AnilistConfig +import io.github.manamiproject.modb.app.TestAppConfig +import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.date.WeekOfYear +import io.github.manamiproject.modb.kitsu.KitsuConfig +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test + +internal class DefaultDownloadControlStateSchedulerTest { + + @Nested + inner class FindEntriesNotScheduledForCurrentWeekTests { + + @Test + fun `same year`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2021, 45), + _anime = AnimeRaw( + _title = "Fruits Basket", + type = TV, + episodes = 26, + picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), + thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), + status = FINISHED, + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 2001, + ), + _sources = hashSetOf(URI("https://kitsu.app/anime/99")), + _synonyms = hashSetOf( + "Furuba", + "フルーツバスケット", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://kitsu.app/anime/41995")), + ), + ), + ) + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 2), + _nextDownload = WeekOfYear(2021, 4), + _anime = AnimeRaw( + _title = "Unexpected", + _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), + ), + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("99") + } + } + + @Test + fun `next yeart`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2022, 2), + _anime = AnimeRaw( + _title = "Fruits Basket", + type = TV, + episodes = 26, + picture = URI("https://media.kitsu.app/anime/poster_images/99/small.jpg?1474922066"), + thumbnail = URI("https://media.kitsu.app/anime/poster_images/99/tiny.jpg?1474922066"), + status = FINISHED, + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 2001, + ), + _sources = hashSetOf(URI("https://kitsu.app/anime/99")), + _synonyms = hashSetOf( + "Furuba", + "フルーツバスケット", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://kitsu.app/anime/41995")), + ), + ), + ) + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 2), + _nextDownload = WeekOfYear(2021, 4), + _anime = AnimeRaw( + _title = "Unexpected", + _sources = hashSetOf(URI("https://kitsu.app/anime/19999999")), + ), + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesNotScheduledForCurrentWeek(KitsuConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("99") + } + } + } + + @Nested + inner class FindEntriesScheduledForCurrentWeekTests { + + @Test + fun `same year`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 2), + _nextDownload = WeekOfYear(2021, 4), + _anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + ) + + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2021, 45), + _anime = AnimeRaw( + _title = "Unexpected Entry", + _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), + ) + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("32") + } + } + + @Test + fun `previous year`() { + tempDirectory { + // given + val testAppConfig = object : Config by TestAppConfig { + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-01-31T16:02:42.00Z"), UTC) + } + + val expectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2020, 49), + _nextDownload = WeekOfYear(2020, 51), + _anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + ) + + val unexpectedEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear(2021, 44), + _nextDownload = WeekOfYear(2021, 45), + _anime = AnimeRaw( + _title = "Unexpected Entry", + _sources = hashSetOf(URI("https://anilist.co/anime/19999999")), + ) + ) + + val testDownloadControlStateAccessor: DownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf( + expectedEntry, + unexpectedEntry, + ) + } + + val defaultDownloadControlStateScheduler = DefaultDownloadControlStateScheduler( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = defaultDownloadControlStateScheduler.findEntriesScheduledForCurrentWeek(AnilistConfig) + + // then + assertThat(result).containsExactlyInAnyOrder("32") + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultDownloadControlStateScheduler.instance + + // when + val result = DefaultDownloadControlStateScheduler.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateScheduler::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt index de4cf90e..9fd2d00a 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DefaultDownloadControlStateUpdaterTest.kt @@ -1,942 +1,942 @@ -package io.github.manamiproject.modb.app.downloadcontrolstate - -import io.github.manamiproject.modb.anilist.AnilistConfig -import io.github.manamiproject.modb.app.TestAppConfig -import io.github.manamiproject.modb.app.TestConfigRegistry -import io.github.manamiproject.modb.app.TestDeadEntriesAccessor -import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor -import io.github.manamiproject.modb.app.TestMetaDataProviderConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.convfiles.CONVERTED_FILE_SUFFIX -import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor -import io.github.manamiproject.modb.app.minusWeeks -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.extensions.Directory -import io.github.manamiproject.modb.core.extensions.RegularFile -import io.github.manamiproject.modb.core.extensions.writeToFile -import io.github.manamiproject.modb.core.config.ConfigRegistry -import io.github.manamiproject.modb.core.json.Json -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.date.WeekOfYear -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneId -import kotlin.io.path.createDirectory -import kotlin.io.path.createFile -import kotlin.test.Test - -internal class DefaultDownloadControlStateUpdaterTest { - - @Nested - inner class UpdateAllTests { - - @Nested - inner class CheckForExtractionProblemsTests { - - @Test - fun `don't trigger anything if there is no conv file`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - } - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - } - } - - @Test - fun `don't throw an exception if the threshold hasn't been reached`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("100")) - ) - val rawFile = rawdata.resolve("100.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("100")) - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val dcsFile = dcs.resolve(testConfig.hostname()).createDirectory().resolve("100.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - } - } - - @Test - fun `triggers alarm at 50 percent differences - score summed up by changes in files`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - // Unchanged anime - val unchangedAnimeId = "100" - val unchangedAnime = AnimeRaw( - _title = "Unchanged anime", - _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)) - ) - val rawFileUnchangedAnime = rawdata.resolve("$unchangedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(unchangedAnime).writeToFile(rawFileUnchangedAnime) - - val dcsUnchangedAnime = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Unchanged anime", - _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)), - ), - ) - val dcsFileUnchangedAnime = metaDataProviderDcsDir.resolve("$unchangedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(dcsUnchangedAnime).writeToFile(dcsFileUnchangedAnime) - - // Changed anime - val changedAnimeId = "200" - val changedAnime = AnimeRaw( - _title = "Changed Anime", - _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)) - ) - val rawFileChangedAnime = rawdata.resolve("$changedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(changedAnime).writeToFile(rawFileChangedAnime) - - val dcsChangedAnime = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Changed Anime", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)), - ), - ) - val dcsFileChangedAnime = metaDataProviderDcsDir.resolve("$changedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(dcsChangedAnime).writeToFile(dcsFileChangedAnime) - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = when (animeId) { - "100" -> dcsUnchangedAnime - "200" -> dcsChangedAnime - else -> shouldNotBeInvoked() - } - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - val result = exceptionExpected<IllegalStateException> { - downloadControlStateUpdater.updateAll() - } - - // then - assertThat(result).hasMessage(""" - Possibly found a problem in the extraction of data: - * example.org with a percentage of 50 - """.trimIndent()) - } - } - } - - @Nested - inner class UpdateChangedIdsTests { - - @Test - fun `treats the old id as dead and keeps the anime under the new id if the provider doesn't support changing ids`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - override fun fileSuffix(): FileSuffix = "html" - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) - ) - val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - rawdata.resolve("previous-test-id.html").createFile() - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - val updated = mutableListOf<AnimeId>() - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - updated.add(animeId) - return true - } - } - - val deadEntries = mutableListOf<AnimeId>() - val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { - override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { - deadEntries.add(animeId) - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - deadEntriesAccessor = testDeadEntriesAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(deadEntries).containsExactly("previous-test-id") - assertThat(updated).containsExactly("new-test-id") - assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() - assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() - assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() - } - } - - @Test - fun `treats the old id as dead and drops the duplicate if the new id was fetched in the same run`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - override fun fileSuffix(): FileSuffix = "html" - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) - ) - val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - rawdata.resolve("previous-test-id.html").createFile() - val newRawFile = rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(newRawFile) - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false - } - - val updated = mutableListOf<AnimeId>() - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - updated.add(animeId) - return true - } - } - - val deadEntries = mutableListOf<AnimeId>() - val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { - override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { - deadEntries.add(animeId) - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - deadEntriesAccessor = testDeadEntriesAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(deadEntries).containsExactly("previous-test-id") - assertThat(updated).containsExactly("new-test-id") - assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() - assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() - assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() - } - } - - @Test - fun `don't throw an exception if the threshold hasn't been reached`() { - tempDirectory { - // given - val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = "example.org" - override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) - override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) - } - - val rawdata = tempDir.resolve("rawdata").createDirectory() - val updatedAnime = AnimeRaw( - _title = "Test", - episodes = 12, - _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) - ) - val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(rawFile) - - val dcs = tempDir.resolve("dcs").createDirectory() - val initialAnime = AnimeRaw( - _title = "Test", - _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), - _nextDownload = WeekOfYear.currentWeek(), - _anime = initialAnime, - ) - val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() - val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() - Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true - } - - var updaterHasBeenExecuted = false - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false - override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { - updaterHasBeenExecuted = true - return dcsFile.parent.resolve("new-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX") - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(updaterHasBeenExecuted).isTrue() - } - } - } - - @Test - fun `creates dcs file if it doesn't exist`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/30")), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - - val expectedDcsEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek(), - _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), - _anime = anime, - ) - - val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(anime).writeToFile(file) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - } - - var receivedDcsEntry: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - receivedDcsEntry = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(receivedDcsEntry).isEqualTo(expectedDcsEntry) - } - } - - @Test - fun `update existing dcs file`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val updatedAnime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/30")), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - - val updatedDcsEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek(), - _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), - _anime = updatedAnime, - ) - - val initialDcsEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(5), - _nextDownload = WeekOfYear.currentWeek(), - _anime = updatedAnime.copy( - duration = Duration.UNKNOWN, - animeSeason = AnimeSeason( - year = AnimeSeason.UNKNOWN_YEAR, - ), - _tags = hashSetOf( - "action", - "mecha", - "sci-fi", - ), - ), - ) - - val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(updatedAnime).writeToFile(file) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - } - - var receivedDcsEntry: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true - override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = initialDcsEntry - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - receivedDcsEntry = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(receivedDcsEntry).isEqualTo(updatedDcsEntry) - } - } - - @Test - fun `can handle same entries with different IDs - the case in which the anime id has changed`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val dcs = tempDir.resolve("dcs").createDirectory() - - val anime = AnimeRaw( - _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(87, MINUTES), - animeSeason = AnimeSeason( - year = 1997, - ), - _sources = hashSetOf(URI("https://anilist.co/anime/32")), - _synonyms = hashSetOf( - "Neon Genesis Evangelion: The End of Evangelion", - "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/30")), - ), - _tags = hashSetOf( - "action", - "drama", - "mecha", - "psychological", - "sci-fi", - ), - ) - - val file1 = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(anime).writeToFile(file1) - - val file2 = rawdata.resolve("64.$CONVERTED_FILE_SUFFIX").createFile() - Json.toJson(anime).writeToFile(file2) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = dcs - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true - override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - } - - var invocations = 0 - var hasBeenInvoked = false - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() - override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir - override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { - hasBeenInvoked = true - return file1 - } - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - invocations++ - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(invocations).isEqualTo(2) - assertThat(hasBeenInvoked).isTrue() - } - } - - @Nested - inner class RescheduleEntriesWhichHaveNotBeenFetchedTests { - - @Test - fun `moves an entry which was due this week, but never fetched, to the following week`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val stranded = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://anilist.co/anime/1535")), - ), - ) - - var received: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(stranded) - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - received = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(received?.lastDownloaded).isEqualTo(stranded.lastDownloaded) - assertThat(received?.weeksWihoutChange).isEqualTo(stranded.weeksWihoutChange) - } - } - - @Test - fun `moves an entry which was due in an earlier week, but never fetched, to the following week`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val overdue = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().plusWeeks(-4), - _nextDownload = WeekOfYear.currentWeek().plusWeeks(-2), - _anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://anilist.co/anime/1535")), - ), - ) - - var received: DownloadControlStateEntry? = null - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(overdue) - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - received = downloadControlStateEntry - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(received?.lastDownloaded).isEqualTo(overdue.lastDownloaded) - assertThat(received?.weeksWihoutChange).isEqualTo(overdue.weeksWihoutChange) - } - } - - @Test - fun `leaves an entry alone if it has been fetched`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val anime = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf(URI("https://anilist.co/anime/1535")), - ) - Json.toJson(anime).writeToFile(rawdata.resolve("1535.$CONVERTED_FILE_SUFFIX").createFile()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val fetched = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek(), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - val receivedWeeks = mutableListOf<WeekOfYear>() - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(fetched) - override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { - receivedWeeks.add(downloadControlStateEntry.nextDownload) - return true - } - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - assertThat(receivedWeeks).hasSize(1) - } - } - - @Test - fun `does not reschedule anything for a crawler which has been switched off`() { - tempDirectory { - // given - val rawdata = tempDir.resolve("rawdata").createDirectory() - val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) - - val testAppConfig = object: Config by TestAppConfig { - override fun downloadControlStateDirectory(): Directory = tempDir - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) - override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata - override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig - override fun clock(): Clock = clock - } - - val testConfigRegistry = object: ConfigRegistry by TestConfigRegistry { - @Suppress("UNCHECKED_CAST") - override fun <T : Any> list(key: String): List<T>? = when (key) { - "modb.app.disabledCrawlers" -> listOf(AnilistConfig.hostname()) as List<T> - else -> null - } - override fun int(key: String): Int? = null - } - - val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { - override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() - override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = shouldNotBeInvoked() - } - - val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( - appConfig = testAppConfig, - downloadControlStateAccessor = testDownloadControlStateAccessor, - configRegistry = testConfigRegistry, - ) - - // when - downloadControlStateUpdater.updateAll() - - // then - // shouldNotBeInvoked in the accessor is the assertion - } - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultDownloadControlStateUpdater.instance - - // when - val result = DefaultDownloadControlStateUpdater.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateUpdater::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.downloadcontrolstate + +import io.github.manamiproject.modb.anilist.AnilistConfig +import io.github.manamiproject.modb.app.TestAppConfig +import io.github.manamiproject.modb.app.TestConfigRegistry +import io.github.manamiproject.modb.app.TestDeadEntriesAccessor +import io.github.manamiproject.modb.app.TestDownloadControlStateAccessor +import io.github.manamiproject.modb.app.TestMetaDataProviderConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.convfiles.CONVERTED_FILE_SUFFIX +import io.github.manamiproject.modb.app.dataset.DeadEntriesAccessor +import io.github.manamiproject.modb.app.minusWeeks +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.extensions.Directory +import io.github.manamiproject.modb.core.extensions.RegularFile +import io.github.manamiproject.modb.core.extensions.writeToFile +import io.github.manamiproject.modb.core.config.ConfigRegistry +import io.github.manamiproject.modb.core.json.Json +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.date.WeekOfYear +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import kotlin.io.path.createDirectory +import kotlin.io.path.createFile +import kotlin.test.Test + +internal class DefaultDownloadControlStateUpdaterTest { + + @Nested + inner class UpdateAllTests { + + @Nested + inner class CheckForExtractionProblemsTests { + + @Test + fun `don't trigger anything if there is no conv file`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + } + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + } + } + + @Test + fun `don't throw an exception if the threshold hasn't been reached`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("100")) + ) + val rawFile = rawdata.resolve("100.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("100")) + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val dcsFile = dcs.resolve(testConfig.hostname()).createDirectory().resolve("100.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + } + } + + @Test + fun `triggers alarm at 50 percent differences - score summed up by changes in files`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + // Unchanged anime + val unchangedAnimeId = "100" + val unchangedAnime = AnimeRaw( + _title = "Unchanged anime", + _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)) + ) + val rawFileUnchangedAnime = rawdata.resolve("$unchangedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(unchangedAnime).writeToFile(rawFileUnchangedAnime) + + val dcsUnchangedAnime = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Unchanged anime", + _sources = hashSetOf(testConfig.buildAnimeLink(unchangedAnimeId)), + ), + ) + val dcsFileUnchangedAnime = metaDataProviderDcsDir.resolve("$unchangedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(dcsUnchangedAnime).writeToFile(dcsFileUnchangedAnime) + + // Changed anime + val changedAnimeId = "200" + val changedAnime = AnimeRaw( + _title = "Changed Anime", + _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)) + ) + val rawFileChangedAnime = rawdata.resolve("$changedAnimeId.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(changedAnime).writeToFile(rawFileChangedAnime) + + val dcsChangedAnime = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Changed Anime", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink(changedAnimeId)), + ), + ) + val dcsFileChangedAnime = metaDataProviderDcsDir.resolve("$changedAnimeId.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(dcsChangedAnime).writeToFile(dcsFileChangedAnime) + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = when (animeId) { + "100" -> dcsUnchangedAnime + "200" -> dcsChangedAnime + else -> shouldNotBeInvoked() + } + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + val result = exceptionExpected<IllegalStateException> { + downloadControlStateUpdater.updateAll() + } + + // then + assertThat(result).hasMessage(""" + Possibly found a problem in the extraction of data: + * example.org with a percentage of 50 + """.trimIndent()) + } + } + } + + @Nested + inner class UpdateChangedIdsTests { + + @Test + fun `treats the old id as dead and keeps the anime under the new id if the provider doesn't support changing ids`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + override fun fileSuffix(): FileSuffix = "html" + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) + ) + val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + rawdata.resolve("previous-test-id.html").createFile() + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + val updated = mutableListOf<AnimeId>() + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + updated.add(animeId) + return true + } + } + + val deadEntries = mutableListOf<AnimeId>() + val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { + override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { + deadEntries.add(animeId) + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + deadEntriesAccessor = testDeadEntriesAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(deadEntries).containsExactly("previous-test-id") + assertThat(updated).containsExactly("new-test-id") + assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() + assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() + assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() + } + } + + @Test + fun `treats the old id as dead and drops the duplicate if the new id was fetched in the same run`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + override fun fileSuffix(): FileSuffix = "html" + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) + ) + val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + rawdata.resolve("previous-test-id.html").createFile() + val newRawFile = rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(newRawFile) + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = false + } + + val updated = mutableListOf<AnimeId>() + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + updated.add(animeId) + return true + } + } + + val deadEntries = mutableListOf<AnimeId>() + val testDeadEntriesAccessor = object: DeadEntriesAccessor by TestDeadEntriesAccessor { + override suspend fun addDeadEntry(animeId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig) { + deadEntries.add(animeId) + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + deadEntriesAccessor = testDeadEntriesAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(deadEntries).containsExactly("previous-test-id") + assertThat(updated).containsExactly("new-test-id") + assertThat(rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX")).doesNotExist() + assertThat(rawdata.resolve("previous-test-id.html")).doesNotExist() + assertThat(rawdata.resolve("new-test-id.$CONVERTED_FILE_SUFFIX")).exists() + } + } + + @Test + fun `don't throw an exception if the threshold hasn't been reached`() { + tempDirectory { + // given + val testConfig = object: MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = "example.org" + override fun buildAnimeLink(id: AnimeId): URI = super.buildAnimeLink(id) + override fun extractAnimeId(uri: URI): AnimeId = super.extractAnimeId(uri) + } + + val rawdata = tempDir.resolve("rawdata").createDirectory() + val updatedAnime = AnimeRaw( + _title = "Test", + episodes = 12, + _sources = hashSetOf(testConfig.buildAnimeLink("new-test-id")) + ) + val rawFile = rawdata.resolve("previous-test-id.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(rawFile) + + val dcs = tempDir.resolve("dcs").createDirectory() + val initialAnime = AnimeRaw( + _title = "Test", + _sources = hashSetOf(testConfig.buildAnimeLink("previous-test-id")) + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(4), + _nextDownload = WeekOfYear.currentWeek(), + _anime = initialAnime, + ) + val metaDataProviderDcsDir = dcs.resolve(testConfig.hostname()).createDirectory() + val dcsFile = metaDataProviderDcsDir.resolve("previous-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX").createFile() + Json.toJson(downloadControlStateEntry).writeToFile(dcsFile) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(testConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = super.findMetaDataProviderConfig(host) + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true + } + + var updaterHasBeenExecuted = false + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = downloadControlStateEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = false + override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { + updaterHasBeenExecuted = true + return dcsFile.parent.resolve("new-test-id.$DOWNLOAD_CONTROL_STATE_FILE_SUFFIX") + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(updaterHasBeenExecuted).isTrue() + } + } + } + + @Test + fun `creates dcs file if it doesn't exist`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + + val expectedDcsEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek(), + _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), + _anime = anime, + ) + + val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(anime).writeToFile(file) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + } + + var receivedDcsEntry: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + receivedDcsEntry = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(receivedDcsEntry).isEqualTo(expectedDcsEntry) + } + } + + @Test + fun `update existing dcs file`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val updatedAnime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + + val updatedDcsEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek(), + _nextDownload = WeekOfYear.currentWeek().plusWeeks(1), + _anime = updatedAnime, + ) + + val initialDcsEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(5), + _nextDownload = WeekOfYear.currentWeek(), + _anime = updatedAnime.copy( + duration = Duration.UNKNOWN, + animeSeason = AnimeSeason( + year = AnimeSeason.UNKNOWN_YEAR, + ), + _tags = hashSetOf( + "action", + "mecha", + "sci-fi", + ), + ), + ) + + val file = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(updatedAnime).writeToFile(file) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + } + + var receivedDcsEntry: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = true + override suspend fun dcsEntry(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): DownloadControlStateEntry = initialDcsEntry + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + receivedDcsEntry = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(receivedDcsEntry).isEqualTo(updatedDcsEntry) + } + } + + @Test + fun `can handle same entries with different IDs - the case in which the anime id has changed`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val dcs = tempDir.resolve("dcs").createDirectory() + + val anime = AnimeRaw( + _title = "Shin Seiki Evangelion Movie: THE END OF EVANGELION", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/32-7YrdcGEX1FP3.png"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(87, MINUTES), + animeSeason = AnimeSeason( + year = 1997, + ), + _sources = hashSetOf(URI("https://anilist.co/anime/32")), + _synonyms = hashSetOf( + "Neon Genesis Evangelion: The End of Evangelion", + "新世紀エヴァンゲリオン劇場版 THE END OF EVANGELION", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/30")), + ), + _tags = hashSetOf( + "action", + "drama", + "mecha", + "psychological", + "sci-fi", + ), + ) + + val file1 = rawdata.resolve("32.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(anime).writeToFile(file1) + + val file2 = rawdata.resolve("64.$CONVERTED_FILE_SUFFIX").createFile() + Json.toJson(anime).writeToFile(file2) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = dcs + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun canChangeAnimeIds(metaDataProviderConfig: MetaDataProviderConfig): Boolean = true + override fun clock(): Clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + } + + var invocations = 0 + var hasBeenInvoked = false + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = emptyList() + override fun downloadControlStateDirectory(metaDataProviderConfig: MetaDataProviderConfig): Directory = tempDir + override suspend fun changeId(oldId: AnimeId, newId: AnimeId, metaDataProviderConfig: MetaDataProviderConfig): RegularFile { + hasBeenInvoked = true + return file1 + } + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + invocations++ + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(invocations).isEqualTo(2) + assertThat(hasBeenInvoked).isTrue() + } + } + + @Nested + inner class RescheduleEntriesWhichHaveNotBeenFetchedTests { + + @Test + fun `moves an entry which was due this week, but never fetched, to the following week`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val stranded = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + ), + ) + + var received: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(stranded) + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + received = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(received?.lastDownloaded).isEqualTo(stranded.lastDownloaded) + assertThat(received?.weeksWihoutChange).isEqualTo(stranded.weeksWihoutChange) + } + } + + @Test + fun `moves an entry which was due in an earlier week, but never fetched, to the following week`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val overdue = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().plusWeeks(-4), + _nextDownload = WeekOfYear.currentWeek().plusWeeks(-2), + _anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + ), + ) + + var received: DownloadControlStateEntry? = null + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(overdue) + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + received = downloadControlStateEntry + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(received?.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(received?.lastDownloaded).isEqualTo(overdue.lastDownloaded) + assertThat(received?.weeksWihoutChange).isEqualTo(overdue.weeksWihoutChange) + } + } + + @Test + fun `leaves an entry alone if it has been fetched`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val anime = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf(URI("https://anilist.co/anime/1535")), + ) + Json.toJson(anime).writeToFile(rawdata.resolve("1535.$CONVERTED_FILE_SUFFIX").createFile()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val fetched = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek(), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + val receivedWeeks = mutableListOf<WeekOfYear>() + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = listOf(fetched) + override suspend fun dcsEntryExists(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId): Boolean = false + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean { + receivedWeeks.add(downloadControlStateEntry.nextDownload) + return true + } + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + assertThat(receivedWeeks).hasSize(1) + } + } + + @Test + fun `does not reschedule anything for a crawler which has been switched off`() { + tempDirectory { + // given + val rawdata = tempDir.resolve("rawdata").createDirectory() + val clock = Clock.fixed(Instant.parse("2021-11-02T17:55:43.035Z"), ZoneId.systemDefault()) + + val testAppConfig = object: Config by TestAppConfig { + override fun downloadControlStateDirectory(): Directory = tempDir + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = setOf(AnilistConfig) + override fun workingDir(metaDataProviderConfig: MetaDataProviderConfig): Directory = rawdata + override fun findMetaDataProviderConfig(host: Hostname): MetaDataProviderConfig = AnilistConfig + override fun clock(): Clock = clock + } + + val testConfigRegistry = object: ConfigRegistry by TestConfigRegistry { + @Suppress("UNCHECKED_CAST") + override fun <T : Any> list(key: String): List<T>? = when (key) { + "modb.app.disabledCrawlers" -> listOf(AnilistConfig.hostname()) as List<T> + else -> null + } + override fun int(key: String): Int? = null + } + + val testDownloadControlStateAccessor = object: DownloadControlStateAccessor by TestDownloadControlStateAccessor { + override suspend fun allDcsEntries(metaDataProviderConfig: MetaDataProviderConfig): List<DownloadControlStateEntry> = shouldNotBeInvoked() + override suspend fun createOrUpdate(metaDataProviderConfig: MetaDataProviderConfig, animeId: AnimeId, downloadControlStateEntry: DownloadControlStateEntry): Boolean = shouldNotBeInvoked() + } + + val downloadControlStateUpdater = DefaultDownloadControlStateUpdater( + appConfig = testAppConfig, + downloadControlStateAccessor = testDownloadControlStateAccessor, + configRegistry = testConfigRegistry, + ) + + // when + downloadControlStateUpdater.updateAll() + + // then + // shouldNotBeInvoked in the accessor is the assertion + } + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultDownloadControlStateUpdater.instance + + // when + val result = DefaultDownloadControlStateUpdater.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultDownloadControlStateUpdater::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt index 1087fcbc..1b9e6db3 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/downloadcontrolstate/DownloadControlStateEntryTest.kt @@ -1,1138 +1,1138 @@ -package io.github.manamiproject.modb.app.downloadcontrolstate - -import io.github.manamiproject.modb.app.minusWeeks -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.date.WeekOfYear -import io.github.manamiproject.modb.test.exceptionExpected -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.EnumSource -import org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE -import org.junit.jupiter.params.provider.EnumSource.Mode.INCLUDE -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class DownloadControlStateEntryTest { - - @Nested - inner class ConstructorTests { - - @Test - fun `throws exception if _weeksWihoutChange is negative`() { - // when - val result = exceptionExpected<IllegalArgumentException> { - DownloadControlStateEntry( - _weeksWihoutChange = -1, - _lastDownloaded = WeekOfYear( - year = 2021, - week = 1, - ), - _nextDownload = WeekOfYear( - year = 2021, - week = 2, - ), - _anime = AnimeRaw("title"), - ) - } - - // then - assertThat(result).hasMessage("_weeksWihoutChange must not be negative.") - } - } - - @Nested - inner class UpdateTests { - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime provides changes then schedule redownload for next week`(status: AnimeStatus) { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw("title"), - ) - - val newAnime = AnimeRaw( - _title = "Title", - episodes = 12, - status = status, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime doesn't change first time schedule redownload in 2 to 4 weeks`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status, - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isOne() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isIn( - WeekOfYear.currentWeek().plusWeeks(2).week, - WeekOfYear.currentWeek().plusWeeks(3).week, - WeekOfYear.currentWeek().plusWeeks(4).week, - ) - assertThat(result.anime).isEqualTo(anime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime didn't change repeatedly expand waiting by the weeks without changes so far`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status, - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isEqualTo(5) - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(5).week) - assertThat(result.anime).isEqualTo(anime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime didn't change repeatedly expand waiting time for 12 weeks maximum`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status, - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 10, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isEqualTo(13) - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(12).week) - assertThat(result.anime).isEqualTo(anime) - } - - @ParameterizedTest - @EnumSource(value = AnimeStatus::class, mode = INCLUDE, names = ["ONGOING", "UPCOMING"]) - fun `if the anime didn't change, but the status is either ONGOING or UPCOMING then schedule redownload for next week`(status: AnimeStatus) { - // given - val anime = AnimeRaw( - _title = "title", - status = status - ) - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = anime, - ) - - // when - val result = downloadControlStateEntry.update(anime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(result.anime) - } - - @Test - fun `different title is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw("title"), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _title = "other", - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different sources is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _sources = hashSetOf(URI("https://example.org/anime/some-title")), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _sources = hashSetOf(URI("https://example.org/anime/other-title")), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different type is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - type = UNKNOWN_TYPE, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - type = MOVIE, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different episodes is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - episodes = 0, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - episodes = 12, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different status is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - status = UNKNOWN_STATUS, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - status = ONGOING, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different animeSeason is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - animeSeason = AnimeSeason( - year = 2025, - ), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - animeSeason = AnimeSeason( - season = FALL, - year = 2025, - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different picture is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - picture = NO_PICTURE, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - picture = URI("https://example.org/media/1.png"), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different thumbnail is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - thumbnail = NO_PICTURE, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - thumbnail = URI("https://example.org/media/1.png"), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different duration is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - duration = UNKNOWN_DURATION, - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - duration = Duration( - value = 20, - unit = MINUTES, - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different synonyms is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _synonyms = hashSetOf("Alternative title"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _synonyms = hashSetOf( - "Alternative title", - "Additional title", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different relatedAnime is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _relatedAnime = hashSetOf(RelatedAnime(URI("https://example.org/anime/some-title"))), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://example.org/anime/some-title")), - RelatedAnime(URI("https://example.org/anime/additional-title")), - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different tags is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _tags = hashSetOf("descriptive tag"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _tags = hashSetOf( - "descriptive tag", - "additional tag", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different studios is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _studios = hashSetOf("main studio"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _studios = hashSetOf( - "main studio", - "additional studio", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different producers is considered a change`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 3, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - _producers = hashSetOf("a producers"), - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy( - _producers = hashSetOf( - "a producer", - "additional producers", - ), - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isZero() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different score is NOT considered a change, therefore next download is scheduled for an unchanged anime, but the score is being updated`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 7.8, - range = 1.0..10.0 - ) - ), - ) - - val newAnime = downloadControlStateEntry.anime.copy().addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 7.9, - range = 1.0..10.0 - ) - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isOne() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isIn( - WeekOfYear.currentWeek().plusWeeks(2).week, - WeekOfYear.currentWeek().plusWeeks(3).week, - WeekOfYear.currentWeek().plusWeeks(4).week, - ) - assertThat(result.anime).isEqualTo(newAnime) - } - - @Test - fun `different activateChecks is NOT considered a change, therefore next download is scheduled for an unchanged anime`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 0, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "title", - activateChecks = false, - ) - ) - - val newAnime = downloadControlStateEntry.anime.copy( - activateChecks = true, - ) - - // when - val result = downloadControlStateEntry.update(newAnime) - - // then - assertThat(result.weeksWihoutChange).isOne() - assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) - assertThat(result.nextDownload.week).isIn( - WeekOfYear.currentWeek().plusWeeks(2).week, - WeekOfYear.currentWeek().plusWeeks(3).week, - WeekOfYear.currentWeek().plusWeeks(4).week, - ) - assertThat(result.anime).isEqualTo(newAnime) - } - } - - @Nested - inner class CalculateQualityScoreTests { - - @Test - fun `decreasing number of synonyms increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _synonyms = hashSetOf("abcd", "efgh"), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _synonyms = hashSetOf("efgh"), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing type to UNKNOWN increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - type = TV, - ), - ) - - val anime = AnimeRaw( - _title = "Test", - type = UNKNOWN_TYPE, - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `setting number of episodes to zero if it was greater than 0 before increases the score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - episodes = 12, - ), - ) - - val anime = AnimeRaw( - _title = "Test", - episodes = 0, - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing status to UNKNOWN increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - status = UPCOMING - ), - ) - - val anime = AnimeRaw( - _title = "Test", - status = UNKNOWN_STATUS, - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing season to UNDEFINED increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = FALL, - year = 2023, - ) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = UNDEFINED, - year = 2023, - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `setting year to zero if it was greater than 0 before increases the score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = FALL, - year = 2023, - ) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - animeSeason = AnimeSeason( - season = FALL, - year = 0, - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing picture to default increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - picture = URI("https://example.org/picture.jpg"), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `changing thumbnail to default increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - thumbnail = URI("https://example.org/picture.jpg"), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `setting duration to zero if it was greater than 0 before increases the score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - duration = Duration( - value = 24, - unit = MINUTES, - ) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `increase score if number if related anime dropped to 0`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _relatedAnime = hashSetOf(RelatedAnime(URI("https://example.org/real1"))) - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `decreasing number of tags increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "abcd", - "efgh", - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _tags = hashSetOf( - "efgh", - ), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `each score indicator is counted`() { - // given - val anime = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - type = UNKNOWN_TYPE, - ) - - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - type = SPECIAL, - episodes = 1, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2009, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), - _synonyms = hashSetOf( - "Clannad ~After Story~: Another World, Kyou Chapter", - "Clannad: After Story OVA", - "クラナド アフターストーリー もうひとつの世界 杏編", - ), - _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2167"))), - _tags = hashSetOf( - "comedy", - "drama", - "romance", - "school", - "slice of life", - "supernatural", - ), - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(11u) - } - - @Test - fun `return 0 if none of the score indicators apply`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", - type = SPECIAL, - episodes = 1, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2009, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), - _synonyms = hashSetOf( - "Clannad ~After Story~: Another World, Kyou Chapter", - "Clannad: After Story OVA", - "クラナド アフターストーリー もうひとつの世界 杏編", - ), - _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2167"))), - _tags = hashSetOf( - "comedy", - "drama", - "romance", - "school", - "slice of life", - "supernatural", - ), - ) - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(downloadControlStateEntry.anime) - - // then - assertThat(result).isEqualTo(0u) - } - - @Test - fun `decreasing number of scores increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - ).addScores( - MetaDataProviderScoreValue( - hostname = "example.org", - value = 75.0, - range = 1.0..100.0, - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `decreasing number of studios increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "abcd", - "efgh", - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _studios = hashSetOf( - "efgh", - ), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - - @Test - fun `decreasing number of producers increases score`() { - // given - val downloadControlStateEntry = DownloadControlStateEntry( - _weeksWihoutChange = 2, - _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), - _nextDownload = WeekOfYear.currentWeek(), - _anime = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "abcd", - "efgh", - ), - ), - ) - - val anime = AnimeRaw( - _title = "Test", - _producers = hashSetOf( - "efgh", - ), - ) - - // when - val result = downloadControlStateEntry.calculateQualityScore(anime) - - // then - assertThat(result).isEqualTo(1u) - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.downloadcontrolstate + +import io.github.manamiproject.modb.app.minusWeeks +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.date.WeekOfYear +import io.github.manamiproject.modb.test.exceptionExpected +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE +import org.junit.jupiter.params.provider.EnumSource.Mode.INCLUDE +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class DownloadControlStateEntryTest { + + @Nested + inner class ConstructorTests { + + @Test + fun `throws exception if _weeksWihoutChange is negative`() { + // when + val result = exceptionExpected<IllegalArgumentException> { + DownloadControlStateEntry( + _weeksWihoutChange = -1, + _lastDownloaded = WeekOfYear( + year = 2021, + week = 1, + ), + _nextDownload = WeekOfYear( + year = 2021, + week = 2, + ), + _anime = AnimeRaw("title"), + ) + } + + // then + assertThat(result).hasMessage("_weeksWihoutChange must not be negative.") + } + } + + @Nested + inner class UpdateTests { + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime provides changes then schedule redownload for next week`(status: AnimeStatus) { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw("title"), + ) + + val newAnime = AnimeRaw( + _title = "Title", + episodes = 12, + status = status, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime doesn't change first time schedule redownload in 2 to 4 weeks`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status, + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isOne() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isIn( + WeekOfYear.currentWeek().plusWeeks(2).week, + WeekOfYear.currentWeek().plusWeeks(3).week, + WeekOfYear.currentWeek().plusWeeks(4).week, + ) + assertThat(result.anime).isEqualTo(anime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime didn't change repeatedly expand waiting by the weeks without changes so far`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status, + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isEqualTo(5) + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(5).week) + assertThat(result.anime).isEqualTo(anime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = EXCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime didn't change repeatedly expand waiting time for 12 weeks maximum`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status, + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 10, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isEqualTo(13) + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isEqualTo(WeekOfYear.currentWeek().plusWeeks(12).week) + assertThat(result.anime).isEqualTo(anime) + } + + @ParameterizedTest + @EnumSource(value = AnimeStatus::class, mode = INCLUDE, names = ["ONGOING", "UPCOMING"]) + fun `if the anime didn't change, but the status is either ONGOING or UPCOMING then schedule redownload for next week`(status: AnimeStatus) { + // given + val anime = AnimeRaw( + _title = "title", + status = status + ) + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = anime, + ) + + // when + val result = downloadControlStateEntry.update(anime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(result.anime) + } + + @Test + fun `different title is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw("title"), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _title = "other", + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different sources is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _sources = hashSetOf(URI("https://example.org/anime/some-title")), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _sources = hashSetOf(URI("https://example.org/anime/other-title")), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different type is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + type = UNKNOWN_TYPE, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + type = MOVIE, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different episodes is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + episodes = 0, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + episodes = 12, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different status is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + status = UNKNOWN_STATUS, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + status = ONGOING, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different animeSeason is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + animeSeason = AnimeSeason( + year = 2025, + ), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + animeSeason = AnimeSeason( + season = FALL, + year = 2025, + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different picture is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + picture = NO_PICTURE, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + picture = URI("https://example.org/media/1.png"), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different thumbnail is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + thumbnail = NO_PICTURE, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + thumbnail = URI("https://example.org/media/1.png"), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different duration is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + duration = UNKNOWN_DURATION, + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + duration = Duration( + value = 20, + unit = MINUTES, + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different synonyms is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _synonyms = hashSetOf("Alternative title"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _synonyms = hashSetOf( + "Alternative title", + "Additional title", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different relatedAnime is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _relatedAnime = hashSetOf(RelatedAnime(URI("https://example.org/anime/some-title"))), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://example.org/anime/some-title")), + RelatedAnime(URI("https://example.org/anime/additional-title")), + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different tags is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _tags = hashSetOf("descriptive tag"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _tags = hashSetOf( + "descriptive tag", + "additional tag", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different studios is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _studios = hashSetOf("main studio"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _studios = hashSetOf( + "main studio", + "additional studio", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different producers is considered a change`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 3, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(3), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + _producers = hashSetOf("a producers"), + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy( + _producers = hashSetOf( + "a producer", + "additional producers", + ), + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isZero() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload).isEqualTo(WeekOfYear.currentWeek().plusWeeks(1)) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different score is NOT considered a change, therefore next download is scheduled for an unchanged anime, but the score is being updated`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 7.8, + range = 1.0..10.0 + ) + ), + ) + + val newAnime = downloadControlStateEntry.anime.copy().addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 7.9, + range = 1.0..10.0 + ) + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isOne() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isIn( + WeekOfYear.currentWeek().plusWeeks(2).week, + WeekOfYear.currentWeek().plusWeeks(3).week, + WeekOfYear.currentWeek().plusWeeks(4).week, + ) + assertThat(result.anime).isEqualTo(newAnime) + } + + @Test + fun `different activateChecks is NOT considered a change, therefore next download is scheduled for an unchanged anime`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 0, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "title", + activateChecks = false, + ) + ) + + val newAnime = downloadControlStateEntry.anime.copy( + activateChecks = true, + ) + + // when + val result = downloadControlStateEntry.update(newAnime) + + // then + assertThat(result.weeksWihoutChange).isOne() + assertThat(result.lastDownloaded).isEqualTo(WeekOfYear.currentWeek()) + assertThat(result.nextDownload.week).isIn( + WeekOfYear.currentWeek().plusWeeks(2).week, + WeekOfYear.currentWeek().plusWeeks(3).week, + WeekOfYear.currentWeek().plusWeeks(4).week, + ) + assertThat(result.anime).isEqualTo(newAnime) + } + } + + @Nested + inner class CalculateQualityScoreTests { + + @Test + fun `decreasing number of synonyms increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _synonyms = hashSetOf("abcd", "efgh"), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _synonyms = hashSetOf("efgh"), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing type to UNKNOWN increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + type = TV, + ), + ) + + val anime = AnimeRaw( + _title = "Test", + type = UNKNOWN_TYPE, + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `setting number of episodes to zero if it was greater than 0 before increases the score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + episodes = 12, + ), + ) + + val anime = AnimeRaw( + _title = "Test", + episodes = 0, + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing status to UNKNOWN increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + status = UPCOMING + ), + ) + + val anime = AnimeRaw( + _title = "Test", + status = UNKNOWN_STATUS, + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing season to UNDEFINED increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = FALL, + year = 2023, + ) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = UNDEFINED, + year = 2023, + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `setting year to zero if it was greater than 0 before increases the score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = FALL, + year = 2023, + ) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + animeSeason = AnimeSeason( + season = FALL, + year = 0, + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing picture to default increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + picture = URI("https://example.org/picture.jpg"), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `changing thumbnail to default increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + thumbnail = URI("https://example.org/picture.jpg"), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `setting duration to zero if it was greater than 0 before increases the score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + duration = Duration( + value = 24, + unit = MINUTES, + ) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `increase score if number if related anime dropped to 0`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _relatedAnime = hashSetOf(RelatedAnime(URI("https://example.org/real1"))) + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `decreasing number of tags increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "abcd", + "efgh", + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _tags = hashSetOf( + "efgh", + ), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `each score indicator is counted`() { + // given + val anime = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + type = UNKNOWN_TYPE, + ) + + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + type = SPECIAL, + episodes = 1, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2009, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), + _synonyms = hashSetOf( + "Clannad ~After Story~: Another World, Kyou Chapter", + "Clannad: After Story OVA", + "クラナド アフターストーリー もうひとつの世界 杏編", + ), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2167"))), + _tags = hashSetOf( + "comedy", + "drama", + "romance", + "school", + "slice of life", + "supernatural", + ), + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(11u) + } + + @Test + fun `return 0 if none of the score indicators apply`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Clannad: After Story - Mou Hitotsu no Sekai, Kyou-hen", + type = SPECIAL, + episodes = 1, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2009, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/10/19621.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/10/19621t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _sources = hashSetOf(URI("https://myanimelist.net/anime/6351")), + _synonyms = hashSetOf( + "Clannad ~After Story~: Another World, Kyou Chapter", + "Clannad: After Story OVA", + "クラナド アフターストーリー もうひとつの世界 杏編", + ), + _relatedAnime = hashSetOf(RelatedAnime(URI("https://myanimelist.net/anime/2167"))), + _tags = hashSetOf( + "comedy", + "drama", + "romance", + "school", + "slice of life", + "supernatural", + ), + ) + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(downloadControlStateEntry.anime) + + // then + assertThat(result).isEqualTo(0u) + } + + @Test + fun `decreasing number of scores increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + ).addScores( + MetaDataProviderScoreValue( + hostname = "example.org", + value = 75.0, + range = 1.0..100.0, + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `decreasing number of studios increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "abcd", + "efgh", + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _studios = hashSetOf( + "efgh", + ), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + + @Test + fun `decreasing number of producers increases score`() { + // given + val downloadControlStateEntry = DownloadControlStateEntry( + _weeksWihoutChange = 2, + _lastDownloaded = WeekOfYear.currentWeek().minusWeeks(1), + _nextDownload = WeekOfYear.currentWeek(), + _anime = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "abcd", + "efgh", + ), + ), + ) + + val anime = AnimeRaw( + _title = "Test", + _producers = hashSetOf( + "efgh", + ), + ) + + // when + val result = downloadControlStateEntry.calculateQualityScore(anime) + + // then + assertThat(result).isEqualTo(1u) + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt index 2c1777ed..77942aa0 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/DefaultMergingServiceTest.kt @@ -1,541 +1,541 @@ -package io.github.manamiproject.modb.app.merging - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.app.TestAppConfig -import io.github.manamiproject.modb.app.TestMergeLockAccessor -import io.github.manamiproject.modb.app.config.AppConfig -import io.github.manamiproject.modb.app.config.Config -import io.github.manamiproject.modb.app.merging.goldenrecords.DefaultGoldenRecordAccessor -import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeSeason -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SUMMER -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.WINTER -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.test.tempDirectory -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test - -internal class DefaultMergingServiceTest { - - @Nested - inner class MergeEntryTests { - - @Test - fun `merge three equal anime with slightly different titles`() { - runTest { - // given - val aniList11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/6682"), - ), - _title = "11eyes", - type = TV, - episodes = 12, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 2009, - ), - _synonyms = hashSetOf( - "11eyes -Tsumi to Batsu to Aganai no Shoujo-", - "イレブンアイズ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/110465")), - RelatedAnime(URI("https://anilist.co/anime/7739")), - ), - ) - - val anidb11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/6751"), - ), - _title = "11 Eyes", - type = TV, - episodes = 12, - picture = URI("https://cdn.anidb.net/images/main/32901.jpg"), - thumbnail = URI("https://cdn.anidb.net/images/main/32901.jpg-thumb.jpg"), - status = FINISHED, - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 2009, - ), - _synonyms = hashSetOf( - "11 akių", - "11 глаз", - "11 چشم", - "11eyes", - "11eyes -罪與罰與贖的少女-", - "11eyes: Tsumi to Batsu to Aganai no Shoujo", - "أحد عشر عيناً", - "イレブンアイズ", - "罪与罚与赎的少女", - ), - ) - - val mal11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/6682"), - ), - _title = "11 - eyes", - type = TV, - episodes = 12, - picture = URI("https://cdn.myanimelist.net/images/anime/6/73520.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/73520t.jpg"), - status = FINISHED, - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 2009, - ), - _synonyms = hashSetOf( - "11eyes -Tsumi to Batsu to Aganai no Shoujo-", - "11eyes イレブンアイズ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/20557")), - RelatedAnime(URI("https://myanimelist.net/anime/7739")), - ), - ) - - val animeList = listOf(aniList11Eyes, anidb11Eyes, mal11Eyes) - - val expectedMergedAnime = anidb11Eyes.copy().apply { - addSources(anidb11Eyes.sources) - addSynonyms(anidb11Eyes.synonyms) - addRelatedAnime(anidb11Eyes.relatedAnime) - addTags(anidb11Eyes.tags) - - } - .mergeWith(aniList11Eyes) - .mergeWith(mal11Eyes) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockAccessor = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockAccessor, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder(expectedMergedAnime) - } - } - - @Test - fun `don't merge two totally different anime`() { - runTest { - // given - val anidb = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/1"), - ), - _title = "Seikai no Monshou", - type = TV, - episodes = 13, - picture = URI("https://cdn.anidb.net/images/main/224618.jpg"), - thumbnail = URI("https://cdn.anidb.net/images/main/224618.jpg-thumb.jpg"), - duration = Duration(25, MINUTES), - animeSeason = AnimeSeason( - year = 1999, - ), - _synonyms = hashSetOf( - "CotS", - "Crest of the Stars", - "Hvězdný erb", - "SnM", - "星界の紋章", - "星界之纹章", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/1623")), - RelatedAnime(URI("https://anidb.net/anime/4")), - RelatedAnime(URI("https://anidb.net/anime/6")), - ), - ) - - val mal = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val animeList = listOf(anidb, mal) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder(anidb, mal) - } - } - - @Test - fun `merge two totally different anime, because they are part of a merge lock`() { - runTest { - // given - val aniList11Eyes = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/6682"), - ), - _title = "11eyes", - type = TV, - episodes = 12, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), - status = FINISHED, - _synonyms = hashSetOf( - "11eyes -Tsumi to Batsu to Aganai no Shoujo-", - "イレブンアイズ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/110465")), - RelatedAnime(URI("https://anilist.co/anime/7739")), - ), - ) - - val bungakuShoujo = AnimeRaw( - _sources = hashSetOf( - URI("https://kitsu.app/anime/4550"), - URI("https://myanimelist.net/anime/6408"), - ), - _title = "\"Bungaku Shoujo\" Movie", - type = MOVIE, - episodes = 1, - status = FINISHED, - picture = URI("https://cdn.myanimelist.net/images/anime/8/81162.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/8/81162t.jpg"), - _synonyms = hashSetOf( - "Book Girl", - "Literature Girl", - "げきじょうばんぶんがくしょうじょ", - "劇場版“文学少女”", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://kitsu.app/anime/5078")), - RelatedAnime(URI("https://kitsu.app/anime/5352")), - RelatedAnime(URI("https://myanimelist.net/anime/7669")), - RelatedAnime(URI("https://myanimelist.net/anime/8481")), - ), - ) - - val expectedEntry = aniList11Eyes.copy().apply { - addSources(aniList11Eyes.sources) - addSynonyms(aniList11Eyes.synonyms) - addRelatedAnime(aniList11Eyes.relatedAnime) - addTags(aniList11Eyes.tags) - }.mergeWith(bungakuShoujo) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = true - override suspend fun getMergeLock(uri: URI): Set<URI> = aniList11Eyes.sources.union(bungakuShoujo.sources) - } - - val animeList = listOf(aniList11Eyes, bungakuShoujo) - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactly(expectedEntry) - } - } - - @Test - fun `multiple golden records`() { - runTest { - // given - val entry1 = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/59110"), - ), - _title = "daydream", - episodes = 1, - animeSeason = AnimeSeason( - season = WINTER, - year = 2024, - ), - status = FINISHED, - type = SPECIAL, - duration = Duration( - value = 120, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "白昼夢", - ), - ) - - val entry2 = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/58364"), - ), - _title = "daydream", - episodes = 1, - type = SPECIAL, - status = FINISHED, - animeSeason = AnimeSeason( - season = WINTER, - year = 2024, - ), - duration = Duration( - value = 120, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "崩壊学園9周年記念楽曲「白昼夢」", - "houkai gakuen 9th anniversary song daydream", - "houkai gakuen 9th anniversary song daydream", - "崩壊学園9周年記念楽曲「白昼夢」", - ), - ) - - val entry3 = AnimeRaw( - _sources = hashSetOf( - URI("https://anisearch.com/anime/19120"), - ), - _title = "Daydream", - episodes = 1, - type = ONA, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2001, - ), - duration = Duration( - value = 240, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "春猿火 - daydream", - ), - ) - - val expectedResult = listOf( - entry1.copy().mergeWith(entry2), - entry3, - ) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val animeList = listOf(entry1, entry3, entry2) - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder( - *expectedResult.toTypedArray(), - ) - } - } - - @Test - fun `found multiple potential golden records, but none returns sufficient probability and therefore the entries set for retry`() { - runTest { - // given - val malCowboyBebop = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - duration = Duration(24, MINUTES), - animeSeason = AnimeSeason( - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val malDaydream = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/59110"), - ), - _title = "daydream", - episodes = 1, - animeSeason = AnimeSeason( - season = WINTER, - year = 2024, - ), - status = FINISHED, - type = SPECIAL, - duration = Duration( - value = 120, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "白昼夢", - ), - ) - - val aniSearchDaydream = AnimeRaw( - _sources = hashSetOf( - URI("https://anisearch.com/anime/19120"), - ), - _title = "Daydream", - episodes = 4, - type = ONA, - status = FINISHED, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2001, - ), - duration = Duration( - value = 240, - unit = SECONDS, - ), - _synonyms = hashSetOf( - "春猿火 - daydream", - ), - ) - - val anilDaydream = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/10001"), - ), - _title = "Daydream", - episodes = 1, - type = MOVIE, - status = UPCOMING, - animeSeason = AnimeSeason( - season = SUMMER, - year = 2006, - ), - duration = Duration( - value = 1, - unit = HOURS, - ), - ) - - val testAppConfig = object: Config by TestAppConfig { - override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() - } - - val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { - override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false - } - - val animeList = listOf(malCowboyBebop, malDaydream, aniSearchDaydream, anilDaydream) - - val defaultMergingService = DefaultMergingService( - appConfig = testAppConfig, - mergeLockAccessor = testMergeLockService, - goldenRecordAccessor = DefaultGoldenRecordAccessor(), - ) - - // when - val result = defaultMergingService.merge(animeList) - - // then - assertThat(result).containsExactlyInAnyOrder( - malCowboyBebop, - malDaydream, - aniSearchDaydream, - anilDaydream, - ) - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultMergingService.instance - - // when - val result = DefaultMergingService.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultMergingService::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.merging + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.app.TestAppConfig +import io.github.manamiproject.modb.app.TestMergeLockAccessor +import io.github.manamiproject.modb.app.config.AppConfig +import io.github.manamiproject.modb.app.config.Config +import io.github.manamiproject.modb.app.merging.goldenrecords.DefaultGoldenRecordAccessor +import io.github.manamiproject.modb.app.merging.lock.MergeLockAccessor +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeSeason +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SUMMER +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.WINTER +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.test.tempDirectory +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test + +internal class DefaultMergingServiceTest { + + @Nested + inner class MergeEntryTests { + + @Test + fun `merge three equal anime with slightly different titles`() { + runTest { + // given + val aniList11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/6682"), + ), + _title = "11eyes", + type = TV, + episodes = 12, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 2009, + ), + _synonyms = hashSetOf( + "11eyes -Tsumi to Batsu to Aganai no Shoujo-", + "イレブンアイズ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/110465")), + RelatedAnime(URI("https://anilist.co/anime/7739")), + ), + ) + + val anidb11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/6751"), + ), + _title = "11 Eyes", + type = TV, + episodes = 12, + picture = URI("https://cdn.anidb.net/images/main/32901.jpg"), + thumbnail = URI("https://cdn.anidb.net/images/main/32901.jpg-thumb.jpg"), + status = FINISHED, + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 2009, + ), + _synonyms = hashSetOf( + "11 akių", + "11 глаз", + "11 چشم", + "11eyes", + "11eyes -罪與罰與贖的少女-", + "11eyes: Tsumi to Batsu to Aganai no Shoujo", + "أحد عشر عيناً", + "イレブンアイズ", + "罪与罚与赎的少女", + ), + ) + + val mal11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/6682"), + ), + _title = "11 - eyes", + type = TV, + episodes = 12, + picture = URI("https://cdn.myanimelist.net/images/anime/6/73520.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/73520t.jpg"), + status = FINISHED, + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 2009, + ), + _synonyms = hashSetOf( + "11eyes -Tsumi to Batsu to Aganai no Shoujo-", + "11eyes イレブンアイズ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/20557")), + RelatedAnime(URI("https://myanimelist.net/anime/7739")), + ), + ) + + val animeList = listOf(aniList11Eyes, anidb11Eyes, mal11Eyes) + + val expectedMergedAnime = anidb11Eyes.copy().apply { + addSources(anidb11Eyes.sources) + addSynonyms(anidb11Eyes.synonyms) + addRelatedAnime(anidb11Eyes.relatedAnime) + addTags(anidb11Eyes.tags) + + } + .mergeWith(aniList11Eyes) + .mergeWith(mal11Eyes) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockAccessor = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockAccessor, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder(expectedMergedAnime) + } + } + + @Test + fun `don't merge two totally different anime`() { + runTest { + // given + val anidb = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/1"), + ), + _title = "Seikai no Monshou", + type = TV, + episodes = 13, + picture = URI("https://cdn.anidb.net/images/main/224618.jpg"), + thumbnail = URI("https://cdn.anidb.net/images/main/224618.jpg-thumb.jpg"), + duration = Duration(25, MINUTES), + animeSeason = AnimeSeason( + year = 1999, + ), + _synonyms = hashSetOf( + "CotS", + "Crest of the Stars", + "Hvězdný erb", + "SnM", + "星界の紋章", + "星界之纹章", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/1623")), + RelatedAnime(URI("https://anidb.net/anime/4")), + RelatedAnime(URI("https://anidb.net/anime/6")), + ), + ) + + val mal = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val animeList = listOf(anidb, mal) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations() = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder(anidb, mal) + } + } + + @Test + fun `merge two totally different anime, because they are part of a merge lock`() { + runTest { + // given + val aniList11Eyes = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/6682"), + ), + _title = "11eyes", + type = TV, + episodes = 12, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx6682-ZptgLsCCNHjL.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/default.jpg"), + status = FINISHED, + _synonyms = hashSetOf( + "11eyes -Tsumi to Batsu to Aganai no Shoujo-", + "イレブンアイズ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/110465")), + RelatedAnime(URI("https://anilist.co/anime/7739")), + ), + ) + + val bungakuShoujo = AnimeRaw( + _sources = hashSetOf( + URI("https://kitsu.app/anime/4550"), + URI("https://myanimelist.net/anime/6408"), + ), + _title = "\"Bungaku Shoujo\" Movie", + type = MOVIE, + episodes = 1, + status = FINISHED, + picture = URI("https://cdn.myanimelist.net/images/anime/8/81162.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/8/81162t.jpg"), + _synonyms = hashSetOf( + "Book Girl", + "Literature Girl", + "げきじょうばんぶんがくしょうじょ", + "劇場版“文学少女”", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://kitsu.app/anime/5078")), + RelatedAnime(URI("https://kitsu.app/anime/5352")), + RelatedAnime(URI("https://myanimelist.net/anime/7669")), + RelatedAnime(URI("https://myanimelist.net/anime/8481")), + ), + ) + + val expectedEntry = aniList11Eyes.copy().apply { + addSources(aniList11Eyes.sources) + addSynonyms(aniList11Eyes.synonyms) + addRelatedAnime(aniList11Eyes.relatedAnime) + addTags(aniList11Eyes.tags) + }.mergeWith(bungakuShoujo) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = true + override suspend fun getMergeLock(uri: URI): Set<URI> = aniList11Eyes.sources.union(bungakuShoujo.sources) + } + + val animeList = listOf(aniList11Eyes, bungakuShoujo) + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactly(expectedEntry) + } + } + + @Test + fun `multiple golden records`() { + runTest { + // given + val entry1 = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/59110"), + ), + _title = "daydream", + episodes = 1, + animeSeason = AnimeSeason( + season = WINTER, + year = 2024, + ), + status = FINISHED, + type = SPECIAL, + duration = Duration( + value = 120, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "白昼夢", + ), + ) + + val entry2 = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/58364"), + ), + _title = "daydream", + episodes = 1, + type = SPECIAL, + status = FINISHED, + animeSeason = AnimeSeason( + season = WINTER, + year = 2024, + ), + duration = Duration( + value = 120, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "崩壊学園9周年記念楽曲「白昼夢」", + "houkai gakuen 9th anniversary song daydream", + "houkai gakuen 9th anniversary song daydream", + "崩壊学園9周年記念楽曲「白昼夢」", + ), + ) + + val entry3 = AnimeRaw( + _sources = hashSetOf( + URI("https://anisearch.com/anime/19120"), + ), + _title = "Daydream", + episodes = 1, + type = ONA, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2001, + ), + duration = Duration( + value = 240, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "春猿火 - daydream", + ), + ) + + val expectedResult = listOf( + entry1.copy().mergeWith(entry2), + entry3, + ) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val animeList = listOf(entry1, entry3, entry2) + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder( + *expectedResult.toTypedArray(), + ) + } + } + + @Test + fun `found multiple potential golden records, but none returns sufficient probability and therefore the entries set for retry`() { + runTest { + // given + val malCowboyBebop = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + duration = Duration(24, MINUTES), + animeSeason = AnimeSeason( + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val malDaydream = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/59110"), + ), + _title = "daydream", + episodes = 1, + animeSeason = AnimeSeason( + season = WINTER, + year = 2024, + ), + status = FINISHED, + type = SPECIAL, + duration = Duration( + value = 120, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "白昼夢", + ), + ) + + val aniSearchDaydream = AnimeRaw( + _sources = hashSetOf( + URI("https://anisearch.com/anime/19120"), + ), + _title = "Daydream", + episodes = 4, + type = ONA, + status = FINISHED, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2001, + ), + duration = Duration( + value = 240, + unit = SECONDS, + ), + _synonyms = hashSetOf( + "春猿火 - daydream", + ), + ) + + val anilDaydream = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/10001"), + ), + _title = "Daydream", + episodes = 1, + type = MOVIE, + status = UPCOMING, + animeSeason = AnimeSeason( + season = SUMMER, + year = 2006, + ), + duration = Duration( + value = 1, + unit = HOURS, + ), + ) + + val testAppConfig = object: Config by TestAppConfig { + override fun metaDataProviderConfigurations(): Set<MetaDataProviderConfig> = AppConfig.instance.metaDataProviderConfigurations() + } + + val testMergeLockService = object: MergeLockAccessor by TestMergeLockAccessor { + override suspend fun hasMergeLock(uris: Set<URI>): Boolean = false + } + + val animeList = listOf(malCowboyBebop, malDaydream, aniSearchDaydream, anilDaydream) + + val defaultMergingService = DefaultMergingService( + appConfig = testAppConfig, + mergeLockAccessor = testMergeLockService, + goldenRecordAccessor = DefaultGoldenRecordAccessor(), + ) + + // when + val result = defaultMergingService.merge(animeList) + + // then + assertThat(result).containsExactlyInAnyOrder( + malCowboyBebop, + malDaydream, + aniSearchDaydream, + anilDaydream, + ) + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultMergingService.instance + + // when + val result = DefaultMergingService.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultMergingService::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt index d34748db..409be2ae 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/goldenrecords/DefaultGoldenRecordAccessorTest.kt @@ -1,1106 +1,1106 @@ -package io.github.manamiproject.modb.app.merging.goldenrecords - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.util.* -import kotlin.test.Test - -internal class DefaultGoldenRecordAccessorTest { - - @Nested - inner class CreateGoldenRecordTests { - - @Test - fun `create a new golden record`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw("Death Note") - - // when - goldenRecordList.createGoldenRecord(anime) - - // then - assertThat(goldenRecordList.allEntries()).containsExactly(anime) - } - - @Test - fun `creating a new golden record based on the exact same anime will create another new golden record, because there is no duplicate check`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw("Death Note") - goldenRecordList.createGoldenRecord(anime) - - // when - goldenRecordList.createGoldenRecord(anime) - - // then - assertThat(goldenRecordList.allEntries()).hasSize(2) - assertThat(goldenRecordList.allEntries()).containsExactly(anime, anime) - } - } - - @Nested - inner class FindGoldenRecordBySources { - - @Test - fun `returns null, because a golden record does not exist for that source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - // when - val result = goldenRecordList.findGoldenRecordBySource(hashSetOf(URI("https://myanimelist.net/anime/1535"))) - - // then - assertThat(result).isNull() - } - - @Test - fun `finds golden record by a single source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/34599")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ) - ) - - goldenRecordList.createGoldenRecord(anime) - - // when - val result = goldenRecordList.findGoldenRecordBySource(anime.sources)!! - - // then - assertThat(result.anime).isEqualTo(anime) - } - - @Test - fun `finds golden record by multiple sources where there is only a golden record for the last source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/34599")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ) - ) - - goldenRecordList.createGoldenRecord(anime) - - // when - val result = goldenRecordList.findGoldenRecordBySource( - hashSetOf( - URI("https://anilist.co/anime/101343"), - URI("https://kitsu.app/anime/41080"), - anime.sources.first(), - ) - )!! - - // then - assertThat(result.anime).isEqualTo(anime) - } - } - - @Nested - inner class FindPossibleGoldenRecordsTests { - - @Test - fun `return empty list for matching episode, but non-matching main title`() { - // given - val anime = AnimeRaw( - _title = "Made in Abyss", - episodes = 13, - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _title = "Searching for", - episodes = 13, - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(anime) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result).isEmpty() - } - - @ParameterizedTest - @ValueSource(strings = ["Kaiketsu Zorori Da-Da-Da-Daibōken!", "Kaiketsu Zorori Da-Da-Da-Daibouken!"]) - fun `finds the same entry whether a long vowel is written with a macron or spelled out`(title: String) { - // given - val goldenRecord = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/14093"), - ), - _title = "Kaiketsu Zorori Da-Da-Da-Daibouken!", - type = MOVIE, - episodes = 1, - ) - - val entryToMatch = AnimeRaw( - _sources = hashSetOf( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=13787"), - ), - _title = title, - type = MOVIE, - episodes = 1, - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(goldenRecord) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) - - // then - assertThat(result).hasSize(1) - assertThat(result.first().anime.title).isEqualTo("Kaiketsu Zorori Da-Da-Da-Daibouken!") - } - - @Test - fun `does not match a title which merely shares a prefix`() { - // given - val goldenRecord = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/14093"), - ), - _title = "Slime Boukenki: Umi da, Yeah", - type = MOVIE, - episodes = 1, - ) - - val entryToMatch = AnimeRaw( - _sources = hashSetOf( - URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=1"), - ), - _title = "Slime Boukenki: Yama da, Yeah", - type = MOVIE, - episodes = 1, - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(goldenRecord) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) - - // then - assertThat(result).isEmpty() - } - - @Test - fun `find correct entry for matching main title`() { - // given - val madeInAbyss = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/34599"), - ), - _title = "Made in Abyss", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/86733.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/86733t.jpg"), - _synonyms = hashSetOf( - "メイドインアビス", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/36862")), - RelatedAnime(URI("https://myanimelist.net/anime/37514")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ), - ) - - val madeInAbyssMovie = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/34599")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ), - ) - - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "A Place Further Than The Universe", - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/37123")), - ), - ) - - val soraYoriMoTooiBashoSpecial = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37123") - ), - _title = "Sora yori mo Tooi Basho: Yokoku", - type = ONA, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), - _synonyms = hashSetOf( - "Sora yori mo Tooi Basho next episode previews", - "宇宙よりも遠い場所 予告 WEB限定", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/35839")), - ), - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/97986"), - ), - _title = "Made in Abyss", - type = TV, - episodes = 13, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(madeInAbyss) - goldenRecordList.createGoldenRecord(madeInAbyssMovie) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(madeInAbyss) - } - - @Test - fun `find correct entry for matching title which is shorter than the partition size`() { - // given - val animeWithTitleShorterThanPartitionSize = AnimeRaw( - _sources = hashSetOf( - URI("https://example.org.net/anime/y"), - ), - _title = "Y", - ) - - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "A Place Further Than The Universe", - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/37123")), - ), - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://otherexample.com/anime/994"), - ), - _title = "Y", - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(animeWithTitleShorterThanPartitionSize) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(animeWithTitleShorterThanPartitionSize) - } - - @ParameterizedTest - @ValueSource(strings = [ - "!", - "¡", - "!", - "?", - "¿", - "?", - "#", - "#", - "%", - "%", - "*", - "*", - "+", - "+", - ",", - ",", - "、", - ".", - ".", - "。", - "・", - "。", - "/", - "/", - "∕", - "⁄", - "\\", - "=", - "=", - "@", - "@", - "|", - "|", - "$", - "$", - "¥", - "¥", - "¢", - "□", - "▲", - "△", - "▼", - "▽", - "◆", - "◇", - "○", - "◎", - "●", - "◯", - "★", - "☆", - "☠", - "♀", - "♂", - "♡", - "♥", - "♪", - "♭", - "♯", - "⚡", - "✞", - "✩", - "✶", - "✽", - "✿", - "❄", - "❤", - "⤴", - "←", - "↑", - "→", - "⇔", - "℃", - "‼", - "⁈", - "※", - "®", - "√", - "…", - "ª", - "ⁿ", - "・", - "·", - "‧", - "、", - "␣", - "∞", - "∅", - "∀", - "ː", - "±", - "°", - "•", - "†", - "\"", - "'", - "^", - "`", - "’", - "‘", - "“", - "”", - "„", - "″", - "〝", - "〟", - "´", - "ʼ", - "‚", - ":", - ":", - ";", - ";", - "-", - "_", - "—", - "―", - "–", - "_", - "‐", - "‑", - "‒", - "−", - "─", - "ー️", - "-", - "ー", - "~", - "∽", - "~", - "〜", - "<", - ">", - "<", - ">", - "(", - ")", - "(", - ")", - "[", - "]", - "[", - "]", - "{", - "}", - "{", - "}", - "「", - "」", - "「", - "」", - "〈", - "〉", - "《", - "》", - "『", - "』", - "【", - "】", - "〔", - "〕", - "≪", - "≫", - "«", - "»", - "≦", - "≧", - "≠", - "∬", - "\u202F", - "\u200A", - "\u205F", - "\u2000", - "\u2001", - "\u2002", - "\u2003", - "\u2004", - "\u2005", - "\u2006", - "\u2007", - "\u2008", - "\u2009", - "", - "᠎", - "⁠", - "‍", - "", - "‌", - "​", - "­", - "", - "
", - " ", - " ", - ]) - fun `find title by ignoring special char`(specialChar: String) { - // given - val titleWithoutSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/34599")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ) - ) - - val titleWithSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://example.org/anime/37514"), - ), - _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(titleWithSpecialChar) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) - } - - @ParameterizedTest - @CsvSource( - "&,&", - "☓,x", - "×,x", - "ß,ss", - "ⅰ,i", - "ⅱ,ii", - "ⅲ,iii", - "ⅳ,iv", - "ⅴ,v", - "ⅵ,vi", - "ⅶ,vii", - "ⅺ,xi", - "½,12", - "⅙,16", - "⅛,18", - "²,2", - "³,3", - "№,no", - "②,2", - "⑤,5", - "a,a", - "b,b", - "c,c", - "d,d", - "e,e", - "f,f", - "g,g", - "h,h", - "i,i", - "j,j", - "k,k", - "l,l", - "m,m", - "n,n", - "o,o", - "p,p", - "q,q", - "r,r", - "s,s", - "t,t", - "u,u", - "v,v", - "w,w", - "x,x", - "y,y", - "z,z", - "0,0", - "1,1", - "2,2", - "3,3", - "4,4", - "5,5", - "6,6", - "7,7", - "8,8", - "9,9", - ) - fun `returns entry, because it correctly replaces special chars`(specialChar: String, replacement: String) { - // given - val titleWithoutSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie $replacement Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/34599")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ) - ) - - val titleWithSpecialChar = AnimeRaw( - _sources = hashSetOf( - URI("https://example.org/anime/37514"), - ), - _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(titleWithSpecialChar) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) - } - - @Test - fun `returns multiple possible golden records, because the anime share the same synonym`() { - // given - val madeInAbyssMovie = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37514"), - ), - _title = "Made in Abyss Movie 1: Tabidachi no Yoake", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 1: Journey's Dawn", - "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け" - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/34599")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ), - ) - - val madeInAbyssMovie2 = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37515"), - ), - _title = "Made in Abyss Movie 2: Hourou Suru Tasogare", - type = MOVIE, - episodes = 1, - picture = URI("https://cdn.myanimelist.net/images/anime/1336/95168.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1336/95168t.jpg"), - _synonyms = hashSetOf( - "Made in Abyss Movie", - "Made in Abyss Movie 2: Wandering Twilight", - "劇場版総集編【後編】メイドインアビス 放浪する黄昏", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/34599")), - RelatedAnime(URI("https://myanimelist.net/anime/37515")), - ) - ) - - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "A Place Further Than The Universe", - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/37123")), - ), - ) - - val soraYoriMoTooiBashoSpecial = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/37123"), - ), - _title = "Sora yori mo Tooi Basho: Yokoku", - type = ONA, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), - _synonyms = hashSetOf( - "Sora yori mo Tooi Basho next episode previews", - "宇宙よりも遠い場所 予告 WEB限定", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/35839")), - ) - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://anilist.co/anime/97986"), - ), - _title = "Made in Abyss Movie", - type = MOVIE, - episodes = 1, - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg") - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(madeInAbyssMovie) - goldenRecordList.createGoldenRecord(madeInAbyssMovie2) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - val resultingRecords = result.map { it.anime } - assertThat(result.size).isEqualTo(2) - assertThat(resultingRecords).contains(madeInAbyssMovie) - assertThat(resultingRecords).contains(madeInAbyssMovie2) - } - - @Test - fun `if nothing could be found for the title and the source is anime-planet then try the first synonym`() { - // given - val soraYoriMoTooiBasho = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/35839"), - ), - _title = "Sora yori mo Tooi Basho", - type = TV, - episodes = 13, - picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), - _synonyms = hashSetOf( - "Uchuu yori mo Tooi Basho", - "A Story That Leads to the Antarctica", - "Yorimoi", - "宇宙よりも遠い場所", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/37123")), - ), - ) - - val animeToFindGoldenRecordFor = AnimeRaw( - _sources = hashSetOf( - URI("https://anime-planet.com/anime/a-place-further-than-the-universe"), - ), - _title = "A Place Further Than the Universe", - type = TV, - episodes = 13, - _synonyms = hashSetOf( - "Sora yori mo Tooi Basho", - ), - ) - - val goldenRecordList = DefaultGoldenRecordAccessor() - goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) - - // when - val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) - - // then - assertThat(result.size).isOne() - assertThat(result.first().anime).isEqualTo(soraYoriMoTooiBasho) - } - } - - @Nested - inner class MergeTests { - - @Test - fun `throws an exception if a golden record cannot be found for the given uuid`() { - // given - val id = UUID.randomUUID() - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _title = "Death Note", - duration = Duration(25, MINUTES), - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - // when - val result = assertThrows<IllegalStateException> { - goldenRecordList.merge(id, anime) - } - - // then - assertThat(result).hasMessage("Unable to find golden record [$id]") - } - - @Test - fun `adds title and synonyms of the other anime to the anime in the golden record`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val goldenAnime = AnimeRaw( - _title = "Death Note", - duration = Duration(25, MINUTES), - _synonyms = hashSetOf( - "Caderno da Morte", - ), - ) - - goldenRecordList.createGoldenRecord(goldenAnime) - val uuid = goldenRecordList.findPossibleGoldenRecords(goldenAnime).first().id - - val otherAnime = AnimeRaw( - _title = "DEATH NOTE", - duration = Duration(25, MINUTES), - _synonyms = hashSetOf( - "Caderno da Morte", - "Quaderno della Morte", - ), - ) - - // when - val result = goldenRecordList.merge(uuid, otherAnime) - - // then - assertThat(result.title).isEqualTo(result.title) - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Caderno da Morte", - "DEATH NOTE", - "Quaderno della Morte", - ) - } - - @Test - fun `merge related anime and source links`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - ) - - goldenRecordList.createGoldenRecord(anime) - val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id - - val otherAnime = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/8146")), - RelatedAnime(URI("https://anidb.net/anime/8147")), - ), - ) - - // when - val result = goldenRecordList.merge(uuid, otherAnime) - - // then - assertThat(result.sources).containsExactly( - URI("https://anidb.net/anime/4563"), - URI("https://myanimelist.net/anime/1535"), - ) - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(URI("https://anidb.net/anime/8146")), - RelatedAnime(URI("https://anidb.net/anime/8147")), - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ) - } - - @Test - fun `cannot merge related anime if it's the other anime's source`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - ) - - goldenRecordList.createGoldenRecord(anime) - val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id - - val otherAnime = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _title = "Death Note", - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/1535")), - RelatedAnime(URI("https://anidb.net/anime/8147")), - ), - ) - - // when - val result = goldenRecordList.merge(uuid, otherAnime) - - // then - assertThat(result.sources).containsExactly( - URI("https://anidb.net/anime/4563"), - URI("https://myanimelist.net/anime/1535"), - ) - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(URI("https://anidb.net/anime/8147")), - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ) - } - - @Test - fun `correctly updates sources cluster`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - _title = "Death Note", - ) - - goldenRecordList.createGoldenRecord(anime) - val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id - - val otherAnime = AnimeRaw( - _sources = hashSetOf( - URI("https://anidb.net/anime/4563"), - ), - _title = "Death Note", - ) - - // when - goldenRecordList.merge(uuid, otherAnime) - - // then - val result = goldenRecordList.findGoldenRecordBySource(setOf(URI("https://anidb.net/anime/4563"))) - assertThat(result).isNotNull() - } - } - - @Nested - inner class ClearTests { - - @Test - fun `clears the list`() { - // given - val goldenRecordList = DefaultGoldenRecordAccessor() - - val anime = AnimeRaw("Death Note") - goldenRecordList.createGoldenRecord(anime) - - // when - goldenRecordList.clear() - - // then - assertThat(goldenRecordList.allEntries()).isEmpty() - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultGoldenRecordAccessor.instance - - // when - val result = DefaultGoldenRecordAccessor.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultGoldenRecordAccessor::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.merging.goldenrecords + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.util.* +import kotlin.test.Test + +internal class DefaultGoldenRecordAccessorTest { + + @Nested + inner class CreateGoldenRecordTests { + + @Test + fun `create a new golden record`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw("Death Note") + + // when + goldenRecordList.createGoldenRecord(anime) + + // then + assertThat(goldenRecordList.allEntries()).containsExactly(anime) + } + + @Test + fun `creating a new golden record based on the exact same anime will create another new golden record, because there is no duplicate check`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw("Death Note") + goldenRecordList.createGoldenRecord(anime) + + // when + goldenRecordList.createGoldenRecord(anime) + + // then + assertThat(goldenRecordList.allEntries()).hasSize(2) + assertThat(goldenRecordList.allEntries()).containsExactly(anime, anime) + } + } + + @Nested + inner class FindGoldenRecordBySources { + + @Test + fun `returns null, because a golden record does not exist for that source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + // when + val result = goldenRecordList.findGoldenRecordBySource(hashSetOf(URI("https://myanimelist.net/anime/1535"))) + + // then + assertThat(result).isNull() + } + + @Test + fun `finds golden record by a single source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + goldenRecordList.createGoldenRecord(anime) + + // when + val result = goldenRecordList.findGoldenRecordBySource(anime.sources)!! + + // then + assertThat(result.anime).isEqualTo(anime) + } + + @Test + fun `finds golden record by multiple sources where there is only a golden record for the last source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + goldenRecordList.createGoldenRecord(anime) + + // when + val result = goldenRecordList.findGoldenRecordBySource( + hashSetOf( + URI("https://anilist.co/anime/101343"), + URI("https://kitsu.app/anime/41080"), + anime.sources.first(), + ) + )!! + + // then + assertThat(result.anime).isEqualTo(anime) + } + } + + @Nested + inner class FindPossibleGoldenRecordsTests { + + @Test + fun `return empty list for matching episode, but non-matching main title`() { + // given + val anime = AnimeRaw( + _title = "Made in Abyss", + episodes = 13, + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _title = "Searching for", + episodes = 13, + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(anime) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result).isEmpty() + } + + @ParameterizedTest + @ValueSource(strings = ["Kaiketsu Zorori Da-Da-Da-Daibōken!", "Kaiketsu Zorori Da-Da-Da-Daibouken!"]) + fun `finds the same entry whether a long vowel is written with a macron or spelled out`(title: String) { + // given + val goldenRecord = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/14093"), + ), + _title = "Kaiketsu Zorori Da-Da-Da-Daibouken!", + type = MOVIE, + episodes = 1, + ) + + val entryToMatch = AnimeRaw( + _sources = hashSetOf( + URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=13787"), + ), + _title = title, + type = MOVIE, + episodes = 1, + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(goldenRecord) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) + + // then + assertThat(result).hasSize(1) + assertThat(result.first().anime.title).isEqualTo("Kaiketsu Zorori Da-Da-Da-Daibouken!") + } + + @Test + fun `does not match a title which merely shares a prefix`() { + // given + val goldenRecord = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/14093"), + ), + _title = "Slime Boukenki: Umi da, Yeah", + type = MOVIE, + episodes = 1, + ) + + val entryToMatch = AnimeRaw( + _sources = hashSetOf( + URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=1"), + ), + _title = "Slime Boukenki: Yama da, Yeah", + type = MOVIE, + episodes = 1, + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(goldenRecord) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(entryToMatch) + + // then + assertThat(result).isEmpty() + } + + @Test + fun `find correct entry for matching main title`() { + // given + val madeInAbyss = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/34599"), + ), + _title = "Made in Abyss", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/86733.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/86733t.jpg"), + _synonyms = hashSetOf( + "メイドインアビス", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/36862")), + RelatedAnime(URI("https://myanimelist.net/anime/37514")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ), + ) + + val madeInAbyssMovie = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ), + ) + + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "A Place Further Than The Universe", + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val soraYoriMoTooiBashoSpecial = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37123") + ), + _title = "Sora yori mo Tooi Basho: Yokoku", + type = ONA, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), + _synonyms = hashSetOf( + "Sora yori mo Tooi Basho next episode previews", + "宇宙よりも遠い場所 予告 WEB限定", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/35839")), + ), + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/97986"), + ), + _title = "Made in Abyss", + type = TV, + episodes = 13, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx97986-fzJBML9qecb4.jpg"), + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(madeInAbyss) + goldenRecordList.createGoldenRecord(madeInAbyssMovie) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(madeInAbyss) + } + + @Test + fun `find correct entry for matching title which is shorter than the partition size`() { + // given + val animeWithTitleShorterThanPartitionSize = AnimeRaw( + _sources = hashSetOf( + URI("https://example.org.net/anime/y"), + ), + _title = "Y", + ) + + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "A Place Further Than The Universe", + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://otherexample.com/anime/994"), + ), + _title = "Y", + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(animeWithTitleShorterThanPartitionSize) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(animeWithTitleShorterThanPartitionSize) + } + + @ParameterizedTest + @ValueSource(strings = [ + "!", + "¡", + "!", + "?", + "¿", + "?", + "#", + "#", + "%", + "%", + "*", + "*", + "+", + "+", + ",", + ",", + "、", + ".", + ".", + "。", + "・", + "。", + "/", + "/", + "∕", + "⁄", + "\\", + "=", + "=", + "@", + "@", + "|", + "|", + "$", + "$", + "¥", + "¥", + "¢", + "□", + "▲", + "△", + "▼", + "▽", + "◆", + "◇", + "○", + "◎", + "●", + "◯", + "★", + "☆", + "☠", + "♀", + "♂", + "♡", + "♥", + "♪", + "♭", + "♯", + "⚡", + "✞", + "✩", + "✶", + "✽", + "✿", + "❄", + "❤", + "⤴", + "←", + "↑", + "→", + "⇔", + "℃", + "‼", + "⁈", + "※", + "®", + "√", + "…", + "ª", + "ⁿ", + "・", + "·", + "‧", + "、", + "␣", + "∞", + "∅", + "∀", + "ː", + "±", + "°", + "•", + "†", + "\"", + "'", + "^", + "`", + "’", + "‘", + "“", + "”", + "„", + "″", + "〝", + "〟", + "´", + "ʼ", + "‚", + ":", + ":", + ";", + ";", + "-", + "_", + "—", + "―", + "–", + "_", + "‐", + "‑", + "‒", + "−", + "─", + "ー️", + "-", + "ー", + "~", + "∽", + "~", + "〜", + "<", + ">", + "<", + ">", + "(", + ")", + "(", + ")", + "[", + "]", + "[", + "]", + "{", + "}", + "{", + "}", + "「", + "」", + "「", + "」", + "〈", + "〉", + "《", + "》", + "『", + "』", + "【", + "】", + "〔", + "〕", + "≪", + "≫", + "«", + "»", + "≦", + "≧", + "≠", + "∬", + "\u202F", + "\u200A", + "\u205F", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "", + "᠎", + "⁠", + "‍", + "", + "‌", + "​", + "­", + "", + "
", + " ", + " ", + ]) + fun `find title by ignoring special char`(specialChar: String) { + // given + val titleWithoutSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + val titleWithSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://example.org/anime/37514"), + ), + _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(titleWithSpecialChar) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) + } + + @ParameterizedTest + @CsvSource( + "&,&", + "☓,x", + "×,x", + "ß,ss", + "ⅰ,i", + "ⅱ,ii", + "ⅲ,iii", + "ⅳ,iv", + "ⅴ,v", + "ⅵ,vi", + "ⅶ,vii", + "ⅺ,xi", + "½,12", + "⅙,16", + "⅛,18", + "²,2", + "³,3", + "№,no", + "②,2", + "⑤,5", + "a,a", + "b,b", + "c,c", + "d,d", + "e,e", + "f,f", + "g,g", + "h,h", + "i,i", + "j,j", + "k,k", + "l,l", + "m,m", + "n,n", + "o,o", + "p,p", + "q,q", + "r,r", + "s,s", + "t,t", + "u,u", + "v,v", + "w,w", + "x,x", + "y,y", + "z,z", + "0,0", + "1,1", + "2,2", + "3,3", + "4,4", + "5,5", + "6,6", + "7,7", + "8,8", + "9,9", + ) + fun `returns entry, because it correctly replaces special chars`(specialChar: String, replacement: String) { + // given + val titleWithoutSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie $replacement Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + val titleWithSpecialChar = AnimeRaw( + _sources = hashSetOf( + URI("https://example.org/anime/37514"), + ), + _title = "Made in Abyss Movie $specialChar Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg") + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(titleWithSpecialChar) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(titleWithoutSpecialChar) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(titleWithSpecialChar) + } + + @Test + fun `returns multiple possible golden records, because the anime share the same synonym`() { + // given + val madeInAbyssMovie = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37514"), + ), + _title = "Made in Abyss Movie 1: Tabidachi no Yoake", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1173/95167.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1173/95167t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 1: Journey's Dawn", + "劇場版総集編【前編】メイドインアビス 旅立ちの夜明け" + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ), + ) + + val madeInAbyssMovie2 = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37515"), + ), + _title = "Made in Abyss Movie 2: Hourou Suru Tasogare", + type = MOVIE, + episodes = 1, + picture = URI("https://cdn.myanimelist.net/images/anime/1336/95168.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1336/95168t.jpg"), + _synonyms = hashSetOf( + "Made in Abyss Movie", + "Made in Abyss Movie 2: Wandering Twilight", + "劇場版総集編【後編】メイドインアビス 放浪する黄昏", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/34599")), + RelatedAnime(URI("https://myanimelist.net/anime/37515")), + ) + ) + + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "A Place Further Than The Universe", + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val soraYoriMoTooiBashoSpecial = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/37123"), + ), + _title = "Sora yori mo Tooi Basho: Yokoku", + type = ONA, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/1897/98785.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1897/98785t.jpg"), + _synonyms = hashSetOf( + "Sora yori mo Tooi Basho next episode previews", + "宇宙よりも遠い場所 予告 WEB限定", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/35839")), + ) + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://anilist.co/anime/97986"), + ), + _title = "Made in Abyss Movie", + type = MOVIE, + episodes = 1, + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx101344-Cx5qshLvAo0j.jpg") + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(madeInAbyssMovie) + goldenRecordList.createGoldenRecord(madeInAbyssMovie2) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + goldenRecordList.createGoldenRecord(soraYoriMoTooiBashoSpecial) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + val resultingRecords = result.map { it.anime } + assertThat(result.size).isEqualTo(2) + assertThat(resultingRecords).contains(madeInAbyssMovie) + assertThat(resultingRecords).contains(madeInAbyssMovie2) + } + + @Test + fun `if nothing could be found for the title and the source is anime-planet then try the first synonym`() { + // given + val soraYoriMoTooiBasho = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/35839"), + ), + _title = "Sora yori mo Tooi Basho", + type = TV, + episodes = 13, + picture = URI("https://cdn.myanimelist.net/images/anime/6/89879.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/6/89879t.jpg"), + _synonyms = hashSetOf( + "Uchuu yori mo Tooi Basho", + "A Story That Leads to the Antarctica", + "Yorimoi", + "宇宙よりも遠い場所", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/37123")), + ), + ) + + val animeToFindGoldenRecordFor = AnimeRaw( + _sources = hashSetOf( + URI("https://anime-planet.com/anime/a-place-further-than-the-universe"), + ), + _title = "A Place Further Than the Universe", + type = TV, + episodes = 13, + _synonyms = hashSetOf( + "Sora yori mo Tooi Basho", + ), + ) + + val goldenRecordList = DefaultGoldenRecordAccessor() + goldenRecordList.createGoldenRecord(soraYoriMoTooiBasho) + + // when + val result = goldenRecordList.findPossibleGoldenRecords(animeToFindGoldenRecordFor) + + // then + assertThat(result.size).isOne() + assertThat(result.first().anime).isEqualTo(soraYoriMoTooiBasho) + } + } + + @Nested + inner class MergeTests { + + @Test + fun `throws an exception if a golden record cannot be found for the given uuid`() { + // given + val id = UUID.randomUUID() + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _title = "Death Note", + duration = Duration(25, MINUTES), + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + // when + val result = assertThrows<IllegalStateException> { + goldenRecordList.merge(id, anime) + } + + // then + assertThat(result).hasMessage("Unable to find golden record [$id]") + } + + @Test + fun `adds title and synonyms of the other anime to the anime in the golden record`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val goldenAnime = AnimeRaw( + _title = "Death Note", + duration = Duration(25, MINUTES), + _synonyms = hashSetOf( + "Caderno da Morte", + ), + ) + + goldenRecordList.createGoldenRecord(goldenAnime) + val uuid = goldenRecordList.findPossibleGoldenRecords(goldenAnime).first().id + + val otherAnime = AnimeRaw( + _title = "DEATH NOTE", + duration = Duration(25, MINUTES), + _synonyms = hashSetOf( + "Caderno da Morte", + "Quaderno della Morte", + ), + ) + + // when + val result = goldenRecordList.merge(uuid, otherAnime) + + // then + assertThat(result.title).isEqualTo(result.title) + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Caderno da Morte", + "DEATH NOTE", + "Quaderno della Morte", + ) + } + + @Test + fun `merge related anime and source links`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + goldenRecordList.createGoldenRecord(anime) + val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id + + val otherAnime = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + ), + ) + + // when + val result = goldenRecordList.merge(uuid, otherAnime) + + // then + assertThat(result.sources).containsExactly( + URI("https://anidb.net/anime/4563"), + URI("https://myanimelist.net/anime/1535"), + ) + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anidb.net/anime/8146")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ) + } + + @Test + fun `cannot merge related anime if it's the other anime's source`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + ) + + goldenRecordList.createGoldenRecord(anime) + val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id + + val otherAnime = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _title = "Death Note", + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/1535")), + RelatedAnime(URI("https://anidb.net/anime/8147")), + ), + ) + + // when + val result = goldenRecordList.merge(uuid, otherAnime) + + // then + assertThat(result.sources).containsExactly( + URI("https://anidb.net/anime/4563"), + URI("https://myanimelist.net/anime/1535"), + ) + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://anidb.net/anime/8147")), + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ) + } + + @Test + fun `correctly updates sources cluster`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + _title = "Death Note", + ) + + goldenRecordList.createGoldenRecord(anime) + val uuid = goldenRecordList.findPossibleGoldenRecords(anime).first().id + + val otherAnime = AnimeRaw( + _sources = hashSetOf( + URI("https://anidb.net/anime/4563"), + ), + _title = "Death Note", + ) + + // when + goldenRecordList.merge(uuid, otherAnime) + + // then + val result = goldenRecordList.findGoldenRecordBySource(setOf(URI("https://anidb.net/anime/4563"))) + assertThat(result).isNotNull() + } + } + + @Nested + inner class ClearTests { + + @Test + fun `clears the list`() { + // given + val goldenRecordList = DefaultGoldenRecordAccessor() + + val anime = AnimeRaw("Death Note") + goldenRecordList.createGoldenRecord(anime) + + // when + goldenRecordList.clear() + + // then + assertThat(goldenRecordList.allEntries()).isEmpty() + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultGoldenRecordAccessor.instance + + // when + val result = DefaultGoldenRecordAccessor.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultGoldenRecordAccessor::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt index e02b76a1..49b934ce 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/merging/matching/DefaultMatchingProbabilityCalculatorTest.kt @@ -1,827 +1,827 @@ -package io.github.manamiproject.modb.app.merging.matching - -import io.github.manamiproject.modb.app.merging.goldenrecords.PotentialGoldenRecord -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING -import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SPRING -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import java.net.URI -import java.util.UUID.randomUUID -import kotlin.test.Test - -internal class DefaultMatchingProbabilityCalculatorTest { - - @Nested - inner class CalculateTests { - - @Nested - inner class BasePropertiesTests { - - @Test - fun `matches 100 percent with basic properties, because the anime for is the same as in the potential golden record`() { - // given - val anime = AnimeRaw("Cowboy Bebop") - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy() - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @Test - fun `title differs - matches 93 percent`() { - // given - val anime = AnimeRaw("Cowboy Bebop") - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - _title = "- Cowboy Bebop -", - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.93) - } - - @Test - fun `type differs - matches 66 percent`() { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - type = TV, - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = MOVIE, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.66) - } - - @ParameterizedTest - @CsvSource( - "SPECIAL,ONA", - "ONA,SPECIAL", - ) - fun `type within special and ona - matches 80 percent`(animeType: String, potentialGoldenRecordType: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - type = AnimeType.of(animeType), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = AnimeType.of(potentialGoldenRecordType), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.8) - } - - @Test - fun `episodes differ - matches 98 percent`() { - // given - val anime = AnimeRaw("Cowboy Bebop") - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - episodes = 1, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(0.98) - } - - @Test - fun `completely different - matches 0 percent`() { - // given - val anime = AnimeRaw( - _title = "ABC", - episodes = 1, - type = MOVIE, - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = AnimeRaw( - _title = "DEF", - episodes = 1500, - type = TV, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(0.0) - } - - @ParameterizedTest - @CsvSource( - "FINISHED,UNKNOWN", - "UNKNOWN,FINISHED", - ) - fun `ignore status if one of one of the two is UNKNOWN`(animeStatus: String, potentialGoldenRecordStatus: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - status = AnimeStatus.of(animeStatus), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - status = AnimeStatus.of(potentialGoldenRecordStatus), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @ParameterizedTest - @CsvSource( - "1998,0", - "0,1998", - ) - fun `ignore yearOfPremiere if one of one of the two is UNKNOWN`(animeYear: String, potentialGoldenRecordYear: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - animeSeason = AnimeSeason( - year = animeYear.toInt(), - ) - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - animeSeason = AnimeSeason( - year = potentialGoldenRecordYear.toInt(), - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @ParameterizedTest - @CsvSource( - "23,0", - "0,23", - ) - fun `ignore duration if one of one of the two is UNKNOWN`(animeMinutes: String, potentialGoldenRecordMinutes: String) { - // given - val anime = AnimeRaw( - _title = "Cowboy Bebop", - duration = Duration( - value = animeMinutes.toInt(), - unit = MINUTES, - ) - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = potentialGoldenRecordMinutes.toInt(), - unit = MINUTES, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.anime).isEqualTo(anime) - assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) - assertThat(result.matchProbability).isEqualTo(1.0) - } - } - - @Nested - inner class AllPropertiesTests { - - @Test - fun `matches 100 percent with all properties, because the anime for is the same as in the potential golden record`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy() - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(1.0) - } - - @Test - fun `title differs - matches 96 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - _title = "- Cowboy Bebop -", - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.96) - } - - @Test - fun `type differs - matches 83 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = MOVIE, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.83) - } - - @ParameterizedTest - @CsvSource( - "SPECIAL,ONA", - "ONA,SPECIAL", - ) - fun `type within special and ona - matches 90 percent`(animeType: String, potentialGoldenRecordType: String) { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = AnimeType.of(animeType), - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - type = AnimeType.of(potentialGoldenRecordType), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.9) - } - - @Test - fun `episodes differ - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - episodes = 27, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `status differs - matches 83 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - status = UPCOMING, - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.83) - } - - @Test - fun `yearOfPremiere differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - animeSeason = anime.animeSeason.copy( - year = 1999, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `duration in minutes differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 24, - unit = MINUTES, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = 23, - unit = MINUTES, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `duration in seconds differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 30, - unit = SECONDS, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = 31, - unit = SECONDS, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `duration in hours differs - matches 99 percent`() { - // given - val anime = AnimeRaw( - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1"), - ), - _title = "Cowboy Bebop", - type = TV, - episodes = 26, - picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), - status = FINISHED, - duration = Duration( - value = 2, - unit = HOURS, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 1998, - ), - _synonyms = hashSetOf( - "カウボーイビバップ", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/17205")), - RelatedAnime(URI("https://myanimelist.net/anime/4037")), - RelatedAnime(URI("https://myanimelist.net/anime/5")), - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = anime.copy( - duration = Duration( - value = 3, - unit = HOURS, - ) - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.99) - } - - @Test - fun `completely different - matches 0 percent`() { - // given - val anime = AnimeRaw( - _title = "ABC", - type = MOVIE, - episodes = 1, - status = UPCOMING, - duration = Duration( - value = 2, - unit = HOURS, - ), - animeSeason = AnimeSeason( - season = SPRING, - year = 2025, - ), - ) - - val potentialGoldenRecord = PotentialGoldenRecord( - id = randomUUID(), - anime = AnimeRaw( - _title = "DEF", - type = TV, - episodes = 1500, - status = FINISHED, - duration = Duration( - value = 30, - unit = SECONDS, - ), - animeSeason = AnimeSeason( - season = FALL, - year = 1981, - ), - ) - ) - - val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() - - // when - val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) - - // then - assertThat(result.matchProbability).isEqualTo(0.0) - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultMatchingProbabilityCalculator.instance - - // when - val result = DefaultMatchingProbabilityCalculator.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultMatchingProbabilityCalculator::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.merging.matching + +import io.github.manamiproject.modb.app.merging.goldenrecords.PotentialGoldenRecord +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeStatus.UPCOMING +import io.github.manamiproject.modb.core.anime.AnimeType.MOVIE +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.SPRING +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import java.net.URI +import java.util.UUID.randomUUID +import kotlin.test.Test + +internal class DefaultMatchingProbabilityCalculatorTest { + + @Nested + inner class CalculateTests { + + @Nested + inner class BasePropertiesTests { + + @Test + fun `matches 100 percent with basic properties, because the anime for is the same as in the potential golden record`() { + // given + val anime = AnimeRaw("Cowboy Bebop") + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy() + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @Test + fun `title differs - matches 93 percent`() { + // given + val anime = AnimeRaw("Cowboy Bebop") + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + _title = "- Cowboy Bebop -", + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.93) + } + + @Test + fun `type differs - matches 66 percent`() { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + type = TV, + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = MOVIE, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.66) + } + + @ParameterizedTest + @CsvSource( + "SPECIAL,ONA", + "ONA,SPECIAL", + ) + fun `type within special and ona - matches 80 percent`(animeType: String, potentialGoldenRecordType: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + type = AnimeType.of(animeType), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = AnimeType.of(potentialGoldenRecordType), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.8) + } + + @Test + fun `episodes differ - matches 98 percent`() { + // given + val anime = AnimeRaw("Cowboy Bebop") + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + episodes = 1, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(0.98) + } + + @Test + fun `completely different - matches 0 percent`() { + // given + val anime = AnimeRaw( + _title = "ABC", + episodes = 1, + type = MOVIE, + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = AnimeRaw( + _title = "DEF", + episodes = 1500, + type = TV, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(0.0) + } + + @ParameterizedTest + @CsvSource( + "FINISHED,UNKNOWN", + "UNKNOWN,FINISHED", + ) + fun `ignore status if one of one of the two is UNKNOWN`(animeStatus: String, potentialGoldenRecordStatus: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + status = AnimeStatus.of(animeStatus), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + status = AnimeStatus.of(potentialGoldenRecordStatus), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @ParameterizedTest + @CsvSource( + "1998,0", + "0,1998", + ) + fun `ignore yearOfPremiere if one of one of the two is UNKNOWN`(animeYear: String, potentialGoldenRecordYear: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + animeSeason = AnimeSeason( + year = animeYear.toInt(), + ) + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + animeSeason = AnimeSeason( + year = potentialGoldenRecordYear.toInt(), + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @ParameterizedTest + @CsvSource( + "23,0", + "0,23", + ) + fun `ignore duration if one of one of the two is UNKNOWN`(animeMinutes: String, potentialGoldenRecordMinutes: String) { + // given + val anime = AnimeRaw( + _title = "Cowboy Bebop", + duration = Duration( + value = animeMinutes.toInt(), + unit = MINUTES, + ) + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = potentialGoldenRecordMinutes.toInt(), + unit = MINUTES, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.anime).isEqualTo(anime) + assertThat(result.potentialGoldenRecord).isEqualTo(potentialGoldenRecord) + assertThat(result.matchProbability).isEqualTo(1.0) + } + } + + @Nested + inner class AllPropertiesTests { + + @Test + fun `matches 100 percent with all properties, because the anime for is the same as in the potential golden record`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy() + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(1.0) + } + + @Test + fun `title differs - matches 96 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + _title = "- Cowboy Bebop -", + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.96) + } + + @Test + fun `type differs - matches 83 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = MOVIE, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.83) + } + + @ParameterizedTest + @CsvSource( + "SPECIAL,ONA", + "ONA,SPECIAL", + ) + fun `type within special and ona - matches 90 percent`(animeType: String, potentialGoldenRecordType: String) { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = AnimeType.of(animeType), + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + type = AnimeType.of(potentialGoldenRecordType), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.9) + } + + @Test + fun `episodes differ - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + episodes = 27, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `status differs - matches 83 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + status = UPCOMING, + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.83) + } + + @Test + fun `yearOfPremiere differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + animeSeason = anime.animeSeason.copy( + year = 1999, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `duration in minutes differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 24, + unit = MINUTES, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = 23, + unit = MINUTES, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `duration in seconds differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 30, + unit = SECONDS, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = 31, + unit = SECONDS, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `duration in hours differs - matches 99 percent`() { + // given + val anime = AnimeRaw( + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1"), + ), + _title = "Cowboy Bebop", + type = TV, + episodes = 26, + picture = URI("https://cdn.myanimelist.net/images/anime/4/19644.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/4/19644t.jpg"), + status = FINISHED, + duration = Duration( + value = 2, + unit = HOURS, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 1998, + ), + _synonyms = hashSetOf( + "カウボーイビバップ", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/17205")), + RelatedAnime(URI("https://myanimelist.net/anime/4037")), + RelatedAnime(URI("https://myanimelist.net/anime/5")), + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = anime.copy( + duration = Duration( + value = 3, + unit = HOURS, + ) + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.99) + } + + @Test + fun `completely different - matches 0 percent`() { + // given + val anime = AnimeRaw( + _title = "ABC", + type = MOVIE, + episodes = 1, + status = UPCOMING, + duration = Duration( + value = 2, + unit = HOURS, + ), + animeSeason = AnimeSeason( + season = SPRING, + year = 2025, + ), + ) + + val potentialGoldenRecord = PotentialGoldenRecord( + id = randomUUID(), + anime = AnimeRaw( + _title = "DEF", + type = TV, + episodes = 1500, + status = FINISHED, + duration = Duration( + value = 30, + unit = SECONDS, + ), + animeSeason = AnimeSeason( + season = FALL, + year = 1981, + ), + ) + ) + + val matchingProbabilityCalculator = DefaultMatchingProbabilityCalculator() + + // when + val result = matchingProbabilityCalculator.calculate(anime, potentialGoldenRecord) + + // then + assertThat(result.matchProbability).isEqualTo(0.0) + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultMatchingProbabilityCalculator.instance + + // when + val result = DefaultMatchingProbabilityCalculator.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultMatchingProbabilityCalculator::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt b/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt index 9389c0b7..89337ad5 100644 --- a/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt +++ b/lib/src/test/kotlin/io/github/manamiproject/modb/app/relatedanime/DefaultUnknownRelatedAnimeRemoverTest.kt @@ -1,92 +1,92 @@ -package io.github.manamiproject.modb.app.relatedanime - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeRaw -import io.github.manamiproject.modb.test.tempDirectory -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test - -internal class DefaultUnknownRelatedAnimeRemoverTest { - - @Nested - inner class RemoveRelatedAnimeWhichHaveNoCorrespondingEntryInSourcesTests { - - @Test - fun `removes related anime listed which don't have a corresponding anime entry`() { - // given - val animeList = listOf( - AnimeRaw( - _title = "1", - _sources = hashSetOf( - URI("http://config1.example.org/anime/1"), - URI("http://config2.example.org/anime/first-entry"), - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("http://config1.example.org/anime/2")), - RelatedAnime(URI("http://config1.example.org/anime/25")), - RelatedAnime(URI("http://config2.example.org/anime/second-entry")), - RelatedAnime(URI("http://config2.example.org/anime/non-existent-entry")), - ) - ), - AnimeRaw( - _title = "2", - _sources = hashSetOf( - URI("http://config1.example.org/anime/2"), - URI("http://config2.example.org/anime/second-entry"), - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("http://config1.example.org/anime/1")), - RelatedAnime(URI("http://config1.example.org/anime/25")), - RelatedAnime(URI("http://config2.example.org/anime/first-entry")), - ), - ), - AnimeRaw( - _title = "3", - _sources = hashSetOf( - URI("http://config2.example.org/anime/third-entry"), - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("http://config2.example.org/anime/non-existent-entry")), - ), - ), - ) - - val defaultUnknownRelatedAnimeRemover = DefaultUnknownRelatedAnimeRemover() - - // when - val result = defaultUnknownRelatedAnimeRemover.removeRelatedAnimeWhichHaveNoCorrespondingEntryInSources(animeList) - - // then - assertThat(result.find { it.title == "1" }!!.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("http://config1.example.org/anime/2")), - RelatedAnime(URI("http://config2.example.org/anime/second-entry")), - ) - assertThat(result.find { it.title == "2" }!!.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("http://config1.example.org/anime/1")), - RelatedAnime(URI("http://config2.example.org/anime/first-entry")), - ) - assertThat(result.find { it.title == "3" }!!.relatedAnime).isEmpty() - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - tempDirectory { - // given - val previous = DefaultUnknownRelatedAnimeRemover.instance - - // when - val result = DefaultUnknownRelatedAnimeRemover.instance - - // then - assertThat(result).isExactlyInstanceOf(DefaultUnknownRelatedAnimeRemover::class.java) - assertThat(result === previous).isTrue() - } - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.app.relatedanime + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeRaw +import io.github.manamiproject.modb.test.tempDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test + +internal class DefaultUnknownRelatedAnimeRemoverTest { + + @Nested + inner class RemoveRelatedAnimeWhichHaveNoCorrespondingEntryInSourcesTests { + + @Test + fun `removes related anime listed which don't have a corresponding anime entry`() { + // given + val animeList = listOf( + AnimeRaw( + _title = "1", + _sources = hashSetOf( + URI("http://config1.example.org/anime/1"), + URI("http://config2.example.org/anime/first-entry"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("http://config1.example.org/anime/2")), + RelatedAnime(URI("http://config1.example.org/anime/25")), + RelatedAnime(URI("http://config2.example.org/anime/second-entry")), + RelatedAnime(URI("http://config2.example.org/anime/non-existent-entry")), + ) + ), + AnimeRaw( + _title = "2", + _sources = hashSetOf( + URI("http://config1.example.org/anime/2"), + URI("http://config2.example.org/anime/second-entry"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("http://config1.example.org/anime/1")), + RelatedAnime(URI("http://config1.example.org/anime/25")), + RelatedAnime(URI("http://config2.example.org/anime/first-entry")), + ), + ), + AnimeRaw( + _title = "3", + _sources = hashSetOf( + URI("http://config2.example.org/anime/third-entry"), + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("http://config2.example.org/anime/non-existent-entry")), + ), + ), + ) + + val defaultUnknownRelatedAnimeRemover = DefaultUnknownRelatedAnimeRemover() + + // when + val result = defaultUnknownRelatedAnimeRemover.removeRelatedAnimeWhichHaveNoCorrespondingEntryInSources(animeList) + + // then + assertThat(result.find { it.title == "1" }!!.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("http://config1.example.org/anime/2")), + RelatedAnime(URI("http://config2.example.org/anime/second-entry")), + ) + assertThat(result.find { it.title == "2" }!!.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("http://config1.example.org/anime/1")), + RelatedAnime(URI("http://config2.example.org/anime/first-entry")), + ) + assertThat(result.find { it.title == "3" }!!.relatedAnime).isEmpty() + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + tempDirectory { + // given + val previous = DefaultUnknownRelatedAnimeRemover.instance + + // when + val result = DefaultUnknownRelatedAnimeRemover.instance + + // then + assertThat(result).isExactlyInstanceOf(DefaultUnknownRelatedAnimeRemover::class.java) + assertThat(result === previous).isTrue() + } + } + } +} \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md index bb9d3864..de99f543 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/animecountdown-with-zero-entries.md @@ -1,399 +1,399 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(0% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 2 | [myanimelist.net](https://myanimelist.net) | -| 0 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### RelatedAnime - -| Field | Type | Nullable | Description | -|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| -| `uri` | `URL` | no | URL to the related anime on the metadata provider. | -| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/8146", - "relation": "Summary" - }, - { - "uri": "https://anidb.net/anime/8147", - "relation": "Summary" - }, - { - "uri": "https://anilist.co/anime/20931" - }, - { - "uri": "https://anilist.co/anime/2994", - "relation": "SUMMARY" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" - }, - { - "uri": "https://animecountdown.com/36687" - }, - { - "uri": "https://animecountdown.com/40690" - }, - { - "uri": "https://anisearch.com/anime/4441" - }, - { - "uri": "https://anisearch.com/anime/5194" - }, - { - "uri": "https://kitsu.app/anime/2707", - "relation": "summary" - }, - { - "uri": "https://livechart.me/anime/3808" - }, - { - "uri": "https://myanimelist.net/anime/2994", - "relation": "Summary" - }, - { - "uri": "https://simkl.com/anime/36687" - }, - { - "uri": "https://simkl.com/anime/40690" - } - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(0% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 2 | [myanimelist.net](https://myanimelist.net) | +| 0 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md index 80213c68..392512b1 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/expected-default.md @@ -1,406 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(0% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### RelatedAnime - -| Field | Type | Nullable | Description | -|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| -| `uri` | `URL` | no | URL to the related anime on the metadata provider. | -| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/8146", - "relation": "Summary" - }, - { - "uri": "https://anidb.net/anime/8147", - "relation": "Summary" - }, - { - "uri": "https://anilist.co/anime/20931" - }, - { - "uri": "https://anilist.co/anime/2994", - "relation": "SUMMARY" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" - }, - { - "uri": "https://animecountdown.com/36687" - }, - { - "uri": "https://animecountdown.com/40690" - }, - { - "uri": "https://anisearch.com/anime/4441" - }, - { - "uri": "https://anisearch.com/anime/5194" - }, - { - "uri": "https://kitsu.app/anime/2707", - "relation": "summary" - }, - { - "uri": "https://livechart.me/anime/3808" - }, - { - "uri": "https://myanimelist.net/anime/2994", - "relation": "Summary" - }, - { - "uri": "https://simkl.com/anime/36687" - }, - { - "uri": "https://simkl.com/anime/40690" - } - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(0% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md index c2e98091..f263e9ec 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/merge-locks.md @@ -1,406 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(50% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### RelatedAnime - -| Field | Type | Nullable | Description | -|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| -| `uri` | `URL` | no | URL to the related anime on the metadata provider. | -| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/8146", - "relation": "Summary" - }, - { - "uri": "https://anidb.net/anime/8147", - "relation": "Summary" - }, - { - "uri": "https://anilist.co/anime/20931" - }, - { - "uri": "https://anilist.co/anime/2994", - "relation": "SUMMARY" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" - }, - { - "uri": "https://animecountdown.com/36687" - }, - { - "uri": "https://animecountdown.com/40690" - }, - { - "uri": "https://anisearch.com/anime/4441" - }, - { - "uri": "https://anisearch.com/anime/5194" - }, - { - "uri": "https://kitsu.app/anime/2707", - "relation": "summary" - }, - { - "uri": "https://livechart.me/anime/3808" - }, - { - "uri": "https://myanimelist.net/anime/2994", - "relation": "Summary" - }, - { - "uri": "https://simkl.com/anime/36687" - }, - { - "uri": "https://simkl.com/anime/40690" - } - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(50% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md index b80b624d..9d816716 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/reviewed-isolated-entries.md @@ -1,406 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 18 [2020]** - -The dataset consists of **2** entries _(100% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### RelatedAnime - -| Field | Type | Nullable | Description | -|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| -| `uri` | `URL` | no | URL to the related anime on the metadata provider. | -| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/8146", - "relation": "Summary" - }, - { - "uri": "https://anidb.net/anime/8147", - "relation": "Summary" - }, - { - "uri": "https://anilist.co/anime/20931" - }, - { - "uri": "https://anilist.co/anime/2994", - "relation": "SUMMARY" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" - }, - { - "uri": "https://animecountdown.com/36687" - }, - { - "uri": "https://animecountdown.com/40690" - }, - { - "uri": "https://anisearch.com/anime/4441" - }, - { - "uri": "https://anisearch.com/anime/5194" - }, - { - "uri": "https://kitsu.app/anime/2707", - "relation": "summary" - }, - { - "uri": "https://livechart.me/anime/3808" - }, - { - "uri": "https://myanimelist.net/anime/2994", - "relation": "Summary" - }, - { - "uri": "https://simkl.com/anime/36687" - }, - { - "uri": "https://simkl.com/anime/40690" - } - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-18/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 18 [2020]** + +The dataset consists of **2** entries _(100% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md index 48002d1e..f054cf3d 100644 --- a/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md +++ b/lib/src/test/resources/readme/DefaultReadmeCreatorTest/zero-based-week.md @@ -1,406 +1,406 @@ -[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-02/LICENSE) - -# anime-offline-database -The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). - -## Statistics -Update **week 02 [2020]** - -The dataset consists of **2** entries _(0% reviewed)_ composed of: - -| Number of entries | Metadata provider | -|-------------------|-------------------| -| 9 | [myanimelist.net](https://myanimelist.net) | -| 8 | [anime-planet.com](https://anime-planet.com) | -| 7 | [kitsu.app](https://kitsu.app) | -| 6 | [anisearch.com](https://anisearch.com) | -| 4 | [anilist.co](https://anilist.co) | -| 3 | [anidb.net](https://anidb.net) | -| 2 | [livechart.me](https://livechart.me) | -| 1 | [simkl.com](https://simkl.com) | -| 1 | [animecountdown.com](https://animecountdown.com) | - - -## Files - -This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. - -| File | Type reference | Description | -|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | -| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | -| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | -| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | -| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | -| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | -| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | -| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | -| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | - -## Type reference - -Here is a description of the types in the JSON files. - -### Database root - -| Field | Type | Nullable | Description | -|--------------|-----------------------------|----------|-------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `data` | [Anime[]](#anime) | no | List of all anime. | - -### License - -| Field | Type | Nullable | Description | -|--------|----------|----------|--------------------------| -| `name` | `String` | no | Name of the license. | -| `url` | `URL` | no | URL to the license file. | - -### ScoreRange - -| Field | Type | Nullable | Description | -|----------------|----------|----------|----------------------------------------------------------| -| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | -| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | - -### Anime - -| Field | Type | Nullable | Description | -|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| -| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | -| `title` | `String` | no | Main title. | -| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | -| `episodes` | `Integer` | no | Number of episodes, movies or parts. | -| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | -| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | -| `picture` | `URL` | no | URL of a picture which represents the anime. | -| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | -| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | -| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | -| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | -| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | -| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | -| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | -| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | - -### RelatedAnime - -| Field | Type | Nullable | Description | -|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| -| `uri` | `URL` | no | URL to the related anime on the metadata provider. | -| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | - -### AnimeSeason - -| Field | Type | Nullable | Description | -|----------|-----------------------------------------------------|----------|---------------------------------------------| -| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | -| `year` | `Integer` | yes | Year of first release. | - -### Duration - -| Field | Type | Nullable | Description | -|----------|-----------|----------|-------------------------------------------------------| -| `value` | `Integer` | no | Duration in seconds. | -| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | - -### Score - -| Field | Type | Nullable | Description | -|---------------------------|----------|----------|--------------------------------------------------------------------------------------| -| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | -| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | -| `median` | `Double` | no | Median based on all available scores from all metadata providers. | - -### DeadEntries root - -| Field | Type | Nullable | Description | -|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| -| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | -| `license` | [License](#license) | no | Information about the license of the dataset. | -| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | -| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | -| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | - -## Examples - -Here are some examples showing what the files look like. - -### anime-offline-database.json: - -<details open> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "scoreRange": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - }, - "lastUpdate": "2025-05-02", - "data": [ - { - "sources": [ - "https://anidb.net/anime/4563", - "https://anilist.co/anime/1535", - "https://anime-planet.com/anime/death-note", - "https://animecountdown.com/40190", - "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", - "https://anisearch.com/anime/3633", - "https://kitsu.app/anime/1376", - "https://livechart.me/anime/3437", - "https://myanimelist.net/anime/1535", - "https://simkl.com/anime/40190" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2006 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": { - "value": 1380, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 8.631697859409492, - "arithmeticMean": 8.631818181818183, - "median": 8.65 - }, - "synonyms": [ - "Bilježnica smrti", - "Caderno da Morte", - "Carnet de la Mort", - "Cuốn sổ tử thần", - "DEATH NOTE", - "DN", - "Death Note - A halállista", - "Death Note - Carnetul morţii", - "Death Note - Zápisník smrti", - "Death Note(デスノート)", - "Mirties Užrašai", - "Notatnik śmierci", - "Notes Śmierci", - "Quaderno della Morte", - "Sveska Smrti", - "Ölüm Defteri", - "Τετράδιο Θανάτου", - "Бележник на Смъртта", - "Записник Смерті", - "Свеска Смрти", - "Тетрадка на Смъртта", - "Тетрадь cмерти", - "Үхлийн Тэмдэглэл", - "डेथ नोट", - "สมุดโน้ตกระชากวิญญาณ", - "ですのーと", - "デスノート", - "死亡笔记", - "死亡筆記本", - "데스노트" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/8146", - "relation": "Summary" - }, - { - "uri": "https://anidb.net/anime/8147", - "relation": "Summary" - }, - { - "uri": "https://anilist.co/anime/20931" - }, - { - "uri": "https://anilist.co/anime/2994", - "relation": "SUMMARY" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" - }, - { - "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" - }, - { - "uri": "https://animecountdown.com/36687" - }, - { - "uri": "https://animecountdown.com/40690" - }, - { - "uri": "https://anisearch.com/anime/4441" - }, - { - "uri": "https://anisearch.com/anime/5194" - }, - { - "uri": "https://kitsu.app/anime/2707", - "relation": "summary" - }, - { - "uri": "https://livechart.me/anime/3808" - }, - { - "uri": "https://myanimelist.net/anime/2994", - "relation": "Summary" - }, - { - "uri": "https://simkl.com/anime/36687" - }, - { - "uri": "https://simkl.com/anime/40690" - } - ], - "tags": [ - "achronological order", - "acting", - "adapted into japanese movie", - "adapted into jdrama", - "adapted into other media", - "adults are useless", - "alternative present", - "americas", - "amnesia", - "anti-hero", - "antihero", - "asexual", - "asia", - "assassins", - "based on a manga", - "battle of wits", - "bishounen", - "canon filler", - "contemporary fantasy", - "contractor", - "cops", - "crime", - "crime fiction", - "criminals", - "death", - "detective", - "detectives", - "drama", - "earth", - "espionage", - "everybody dies", - "fantasy", - "feet", - "following one`s dream", - "genius", - "gods", - "grail in the garbage", - "hero of strong character", - "horror", - "insane", - "japan", - "japanese production", - "journalism", - "just as planned", - "kamis", - "kuudere", - "law and order", - "male protagonist", - "manga", - "memory manipulation", - "mind games", - "mundane made awesome", - "murder", - "mystery", - "overpowered main characters", - "philosophy", - "place", - "plot continuity", - "police", - "police are useless", - "policeman", - "predominantly adult cast", - "present", - "primarily adult cast", - "primarily male cast", - "psychological", - "psychological drama", - "psychopaths", - "real-world location", - "rivalries", - "rivalry", - "romance", - "school life", - "secret identity", - "serial killers", - "shinigami", - "shounen", - "speculative fiction", - "suicide", - "supernatural", - "supernatural drama", - "supernatural thriller", - "suspense", - "tennis", - "thriller", - "time", - "time skip", - "tragedy", - "tropes", - "twisted story", - "united states", - "university", - "unrequited love", - "unusual weapons -- to be split and deleted", - "urban", - "urban fantasy", - "vigilantes", - "weekly shounen jump", - "world domination", - "yandere" - ] - } - ] -} -``` -</details> - -### dead-entries/*.json - -<details> -<summary>Example</summary> - -```json -{ - "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", - "license": { - "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", - "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" - }, - "repository": "https://github.com/manami-project/anime-offline-database", - "lastUpdate": "2025-05-02", - "deadEntries": [ - "38492", - "38518", - "38522", - "38531" - ] -} -``` -</details> \ No newline at end of file +[![License: ODbL-1.0](https://img.shields.io/badge/license-ODbL--1.0-orange)](https://github.com/manami-project/anime-offline-database/blob/2020-02/LICENSE) + +# anime-offline-database +The purpose of this repository is to create a dataset containing anime metadata aggregated by different anime metadata providers (such as myanimelist.net, anidb.net, kitsu.app and more) and allow cross-references between those metadata providers. This dataset is supposed to be used by and created for [manami](https://github.com/manami-project/manami). + +## Statistics +Update **week 02 [2020]** + +The dataset consists of **2** entries _(0% reviewed)_ composed of: + +| Number of entries | Metadata provider | +|-------------------|-------------------| +| 9 | [myanimelist.net](https://myanimelist.net) | +| 8 | [anime-planet.com](https://anime-planet.com) | +| 7 | [kitsu.app](https://kitsu.app) | +| 6 | [anisearch.com](https://anisearch.com) | +| 4 | [anilist.co](https://anilist.co) | +| 3 | [anidb.net](https://anidb.net) | +| 2 | [livechart.me](https://livechart.me) | +| 1 | [simkl.com](https://simkl.com) | +| 1 | [animecountdown.com](https://animecountdown.com) | + + +## Files + +This repository contains various JSON and zip files. The dataset file itself as well as files containing IDs of dead entries for some metadata providers to support the automated process. + +| File | Type reference | Description | +|-----------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `anime-offline-database-minified.json` | [Database root](#database-root) | Minified version of `anime-offline-database.json`. Values containing `null` are omitted. | +| `anime-offline-database-minified.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating `anime-offline-database-minified.json`. | +| `anime-offline-database-minified.json.zst` | [Database root](#database-root) | Zstandard compressed file of `anime-offline-database-minified.json`. | +| `anime-offline-database.jsonl` | [Anime](#anime) | [JSON lines](https://jsonlines.org) file containing anime. Each line is an anime object except for the first line which contains metadata. | +| `anime-offline-database.jsonl.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating each line within `anime-offline-database.jsonl` | +| `anime-offline-database.jsonl.zst` | [Anime](#anime) | Zstandard compressed file of `anime-offline-database.jsonl` | +| `dead-entries/dead-entries.schema.json` | [JSON schema](https://json-schema.org) | JSON schema file for validating all the `dead-entries/*-minified.json` and `dead-entries/*.json` files. | +| `dead-entries/*-minified.json` | [DeadEntries root](#deadentries-root) | A file where `*` is the name of the respective metadata provider. Contains anime IDs which have been removed from the metadata provider. Minified. | +| `dead-entries/*-minified.json.zst` | [DeadEntries root](#deadentries-root) | Zstandard compressed file of the corresponding `dead-entries/*.json` file. | + +## Type reference + +Here is a description of the types in the JSON files. + +### Database root + +| Field | Type | Nullable | Description | +|--------------|-----------------------------|----------|-------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `scoreRange` | [ScoreRange](#scorerange) | no | Describes the upper and lower boundaries of a score. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `data` | [Anime[]](#anime) | no | List of all anime. | + +### License + +| Field | Type | Nullable | Description | +|--------|----------|----------|--------------------------| +| `name` | `String` | no | Name of the license. | +| `url` | `URL` | no | URL to the license file. | + +### ScoreRange + +| Field | Type | Nullable | Description | +|----------------|----------|----------|----------------------------------------------------------| +| `minInclusive` | `Double` | no | Minimum value that a score can take. **Default:** `1.0` | +| `maxInclusive` | `Double` | no | Maximum value that a score can take. **Default:** `10.0` | + +### Anime + +| Field | Type | Nullable | Description | +|----------------|---------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------| +| `sources` | `URL[]` | no | URLs to the pages of the metadata providers for this anime. | +| `title` | `String` | no | Main title. | +| `type` | `Enum of [TV, MOVIE, OVA, ONA, SPECIAL, UNKNOWN]` | no | Distribution type. | +| `episodes` | `Integer` | no | Number of episodes, movies or parts. | +| `status` | `Enum of [FINISHED, ONGOING, UPCOMING, UNKNOWN]` | no | Status of distribution. | +| `animeSeason` | [AnimeSeason](#animeseason) | no | Data on when the anime was first distributed. | +| `picture` | `URL` | no | URL of a picture which represents the anime. | +| `thumbnail` | `URL` | no | URL of a smaller version of the picture. | +| `duration` | [Duration](#duration) | yes | Duration. Normally this is per episode. | +| `score` | [Score](#score) | yes | Score calculated using all available scores from metadata providers. Original scores are rescaled if necessary. | +| `synonyms` | `String[]` | no | Alternative titles and spellings under which the anime is also known. Duplicate free (case-sensitive). Doesn't contain the `title` | +| `studios` | `String[]` | no | Lower case studio names. In general a duplicate free list, but might contain duplicates for different writings. | +| `producers` | `String[]` | no | Lower case producers names. Companies only. In general a duplicate free list, but might contain duplicates for different writings. | +| `relatedAnime` | [RelatedAnime[]](#relatedanime) | no | Anime on the metadata providers which are related to this anime, including the kind of relation if the provider names it. | +| `tags` | `String[]` | no | A non-curated list of tags and genres which describe the anime. All entries are lower case. | + +### RelatedAnime + +| Field | Type | Nullable | Description | +|------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `uri` | `URL` | no | URL to the related anime on the metadata provider. | +| `relation` | `String` | yes | Kind of relation exactly as the metadata provider labels it, e.g. `Sequel` (myanimelist), `SEQUEL` (anilist), `sequel` (kitsu). Not unified. Missing if the provider doesn't name it. | + +### AnimeSeason + +| Field | Type | Nullable | Description | +|----------|-----------------------------------------------------|----------|---------------------------------------------| +| `season` | `Enum of [SPRING, SUMMER, FALL, WINTER, UNDEFINED]` | no | Season in which the first release occurred. | +| `year` | `Integer` | yes | Year of first release. | + +### Duration + +| Field | Type | Nullable | Description | +|----------|-----------|----------|-------------------------------------------------------| +| `value` | `Integer` | no | Duration in seconds. | +| `unit` | `String` | no | For (de)serialization this value is always `SECONDS`. | + +### Score + +| Field | Type | Nullable | Description | +|---------------------------|----------|----------|--------------------------------------------------------------------------------------| +| `arithmeticGeometricMean` | `Double` | no | Arithmetic–geometric mean based on all available scores from all metadata providers. | +| `arithmeticMean` | `Double` | no | Arithmetic mean based on all available scores from all metadata providers. | +| `median` | `Double` | no | Median based on all available scores from all metadata providers. | + +### DeadEntries root + +| Field | Type | Nullable | Description | +|---------------|-----------------------------|----------|-----------------------------------------------------------------------------| +| `$schema` | `URI` | no | Link to the JSON schema which allows to validate the content. | +| `license` | [License](#license) | no | Information about the license of the dataset. | +| `repository` | `URL` | no | URL of this github repository which is the source of the dataset. | +| `lastUpdate` | `Date` (format: YYYY-MM-DD) | no | The date on which the file was updated. | +| `deadEntries` | `String[]` | no | IDs of anime which have been removed from the respective metadata provider. | + +## Examples + +Here are some examples showing what the files look like. + +### anime-offline-database.json: + +<details open> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/anime-offline-database.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "scoreRange": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + }, + "lastUpdate": "2025-05-02", + "data": [ + { + "sources": [ + "https://anidb.net/anime/4563", + "https://anilist.co/anime/1535", + "https://anime-planet.com/anime/death-note", + "https://animecountdown.com/40190", + "https://animenewsnetwork.com/encyclopedia/anime.php?id=6592", + "https://anisearch.com/anime/3633", + "https://kitsu.app/anime/1376", + "https://livechart.me/anime/3437", + "https://myanimelist.net/anime/1535", + "https://simkl.com/anime/40190" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2006 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": { + "value": 1380, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 8.631697859409492, + "arithmeticMean": 8.631818181818183, + "median": 8.65 + }, + "synonyms": [ + "Bilježnica smrti", + "Caderno da Morte", + "Carnet de la Mort", + "Cuốn sổ tử thần", + "DEATH NOTE", + "DN", + "Death Note - A halállista", + "Death Note - Carnetul morţii", + "Death Note - Zápisník smrti", + "Death Note(デスノート)", + "Mirties Užrašai", + "Notatnik śmierci", + "Notes Śmierci", + "Quaderno della Morte", + "Sveska Smrti", + "Ölüm Defteri", + "Τετράδιο Θανάτου", + "Бележник на Смъртта", + "Записник Смерті", + "Свеска Смрти", + "Тетрадка на Смъртта", + "Тетрадь cмерти", + "Үхлийн Тэмдэглэл", + "डेथ नोट", + "สมุดโน้ตกระชากวิญญาณ", + "ですのーと", + "デスノート", + "死亡笔记", + "死亡筆記本", + "데스노트" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/8146", + "relation": "Summary" + }, + { + "uri": "https://anidb.net/anime/8147", + "relation": "Summary" + }, + { + "uri": "https://anilist.co/anime/20931" + }, + { + "uri": "https://anilist.co/anime/2994", + "relation": "SUMMARY" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-1-visions-of-a-god" + }, + { + "uri": "https://anime-planet.com/anime/death-note-rewrite-2-ls-successors" + }, + { + "uri": "https://animecountdown.com/36687" + }, + { + "uri": "https://animecountdown.com/40690" + }, + { + "uri": "https://anisearch.com/anime/4441" + }, + { + "uri": "https://anisearch.com/anime/5194" + }, + { + "uri": "https://kitsu.app/anime/2707", + "relation": "summary" + }, + { + "uri": "https://livechart.me/anime/3808" + }, + { + "uri": "https://myanimelist.net/anime/2994", + "relation": "Summary" + }, + { + "uri": "https://simkl.com/anime/36687" + }, + { + "uri": "https://simkl.com/anime/40690" + } + ], + "tags": [ + "achronological order", + "acting", + "adapted into japanese movie", + "adapted into jdrama", + "adapted into other media", + "adults are useless", + "alternative present", + "americas", + "amnesia", + "anti-hero", + "antihero", + "asexual", + "asia", + "assassins", + "based on a manga", + "battle of wits", + "bishounen", + "canon filler", + "contemporary fantasy", + "contractor", + "cops", + "crime", + "crime fiction", + "criminals", + "death", + "detective", + "detectives", + "drama", + "earth", + "espionage", + "everybody dies", + "fantasy", + "feet", + "following one`s dream", + "genius", + "gods", + "grail in the garbage", + "hero of strong character", + "horror", + "insane", + "japan", + "japanese production", + "journalism", + "just as planned", + "kamis", + "kuudere", + "law and order", + "male protagonist", + "manga", + "memory manipulation", + "mind games", + "mundane made awesome", + "murder", + "mystery", + "overpowered main characters", + "philosophy", + "place", + "plot continuity", + "police", + "police are useless", + "policeman", + "predominantly adult cast", + "present", + "primarily adult cast", + "primarily male cast", + "psychological", + "psychological drama", + "psychopaths", + "real-world location", + "rivalries", + "rivalry", + "romance", + "school life", + "secret identity", + "serial killers", + "shinigami", + "shounen", + "speculative fiction", + "suicide", + "supernatural", + "supernatural drama", + "supernatural thriller", + "suspense", + "tennis", + "thriller", + "time", + "time skip", + "tragedy", + "tropes", + "twisted story", + "united states", + "university", + "unrequited love", + "unusual weapons -- to be split and deleted", + "urban", + "urban fantasy", + "vigilantes", + "weekly shounen jump", + "world domination", + "yandere" + ] + } + ] +} +``` +</details> + +### dead-entries/*.json + +<details> +<summary>Example</summary> + +```json +{ + "$schema": "https://raw.githubusercontent.com/manami-project/anime-offline-database/refs/tags/2025-18/schemas/dead-entries.schema.json", + "license": { + "name": "Open Data Commons Open Database License (ODbL) v1.0 + Database Contents License (DbCL) v1.0", + "url": "https://github.com/manami-project/anime-offline-database/blob/2025-18/LICENSE" + }, + "repository": "https://github.com/manami-project/anime-offline-database", + "lastUpdate": "2025-05-02", + "deadEntries": [ + "38492", + "38518", + "38522", + "38531" + ] +} +``` +</details> \ No newline at end of file diff --git a/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt b/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt index 0164949d..37ed9653 100644 --- a/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt +++ b/livechart/src/main/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverter.kt @@ -1,337 +1,337 @@ -package io.github.manamiproject.modb.livechart - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.normalize -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.JsonDataExtractor -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import kotlinx.coroutines.withContext -import org.apache.commons.text.StringEscapeUtils -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property xmlExtractor Extractor which retrieves the data from raw data. - * @property jsonExtractor Extractor which retrieves the data from raw data. - */ -public class LivechartAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = LivechartConfig, - private val xmlExtractor: DataExtractor = XmlDataExtractor, - private val jsonExtractor: DataExtractor = JsonDataExtractor, -): AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = xmlExtractor.extract(rawContent, mapOf( - "jsonld" to "//script[@type='application/ld+json']/node()", - "title" to "//meta[@property='og:title']/@content", - "image" to "//meta[@property='og:image']/@content", - "episodesDiv" to "//div[text()='Episodes']/../text()", - "episodesCountdown" to "//div[@data-controller='countdown-bar']//div[contains(text(), 'EP')]/text()", - "type" to "//div[text()='Format']/..", - "status" to "//div[text()='Status']/..", - "duration" to "//div[text()='Run time']/..", - "season" to "//div[text()='Season']/../a/text()", - "year" to "//div[text()='Premiere']/following-sibling::*", - "relatedAnime" to "//div[@data-controller='carousel']//article/a/@href", - "tags" to "//div[text()='Tags']/..//a[@data-anime-details-target='tagChip']", - "sourceDiv" to "//div[@data-anime-details-id]/@data-anime-details-id", - "sourceMeta" to "//meta[@property='og:url']/@content", - "studios" to "//a[contains(@href, 'studios')]/text()", - )) - - val jsonld = data.listNotNull<String>("jsonld").first() - val jsonldData = jsonExtractor.extract(jsonld, mapOf( - "url" to "$.url", - "genre" to "$.genre", - "name" to "$.name", - "image" to "$.image", - "numberOfEpisodes" to "$.numberOfEpisodes", - "datePublished" to "$.datePublished", - "alternateName" to "$.alternateName", - "score" to "$.aggregateRating.ratingValue", - "worstRating" to "$.aggregateRating.worstRating", - "bestRating" to "$.aggregateRating.bestRating", - "productionCompany" to "$.productionCompany.*.name", - )) - - val picture = extractPicture(jsonldData, data) - - return@withContext AnimeRaw( - _title = extractTitle(jsonldData, data), - episodes = extractEpisodes(jsonldData, data), - type = extractType(data), - picture = picture, - thumbnail = findThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(jsonldData, data), - _synonyms = extractSynonyms(jsonldData), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(jsonldData, data), - _studios = extractStudios(jsonldData, data), - _producers = hashSetOf(), // not available on livechart - ).addScores(extractScore(jsonldData)) - } - - private fun extractTitle(jsonData: ExtractionResult, data: ExtractionResult): Title { - val name = jsonData.stringOrDefault("name").ifBlank { - data.stringOrDefault("title") - } - - return StringEscapeUtils.unescapeHtml4(name) - } - - private fun extractEpisodes(jsonData: ExtractionResult, data: ExtractionResult): Episodes { - // JSON data - var episodes = jsonData.intOrDefault("numberOfEpisodes") - var isTableValueUnknown = false - - // regular value in data table - if (episodes == 0) { - val episodesValue = data.stringOrDefault("episodesDiv", "?") - .trim() - .split('/') - .last() - - if (episodesValue != "?" && episodesValue != "-") { - episodes = episodesValue.toIntOrNull() ?: 0 - } else { - isTableValueUnknown = true - } - } - - // current episode from table if the anime is ongoing - if (episodes == 0) { - episodes = data.stringOrDefault("episodesCountdown") - .remove("EP") - .trim() - .toIntOrNull() - ?: 0 - - if (episodes != 0 ) { - episodes -= 1 - } - } - - return when { - episodes == 0 && isTableValueUnknown -> 1 - else -> episodes - } - } - - private fun extractType(data: ExtractionResult): AnimeType { - val value = data.string("type") - - return when(value.trim().lowercase()) { - "movie" -> MOVIE - "ova" -> OVA - "special" -> SPECIAL - "tv" -> TV - "tv short" -> TV - "tv special" -> SPECIAL - "web" -> ONA - "web short" -> ONA - "?" -> UNKNOWN_TYPE - "unknown" -> UNKNOWN_TYPE - else -> throw IllegalStateException("Unknown type [$value]") - } - } - - private fun extractPicture(jsonData: ExtractionResult, data: ExtractionResult): URI { - val value = jsonData.stringOrDefault("image").ifBlank { - data.stringOrDefault("image") - } - - return if (!value.endsWith(LARGE_PICTURE_INDICATOR)) { - NO_PICTURE - } else { - URI(value) - } - } - - private fun findThumbnail(uri: URI): URI { - return if (uri == NO_PICTURE) { - NO_PICTURE_THUMBNAIL - } else { - URI(uri.toString().replace(LARGE_PICTURE_INDICATOR, SMALL_PICTURE_INDICATOR)) - } - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val statusString = data.stringOrDefault("status") - - return when (statusString.trim().lowercase()){ - "not yet released" -> UPCOMING - "releasing" -> ONGOING - "finished" -> FINISHED - else -> UNKNOWN_STATUS - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - val durationString = data.stringOrDefault("duration").trim() - - val seconds = """(\d+ ?[aA-zZ]+)+""".toRegex() - .findAll(durationString) - .map { it.value } - .map { - val value = ("""\d+""".toRegex().find(it)?.value?.trim() ?: "0").ifBlank { "0" }.toIntOrNull() ?: 0 - val unit = """[a-z]+""".toRegex().find(it)?.value?.trim()?.lowercase() ?: "" - value to unit - } - .map { - when(it.second) { - "hr", "h" -> it.first * 3600 - "min", "m" -> it.first * 60 - "sec", "s" -> it.first - else -> throw IllegalStateException("Unknown unit [${it.second}]") - } - } - .sum() - - return Duration(seconds, SECONDS) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val splitSeasonString = if (data.notFound("season")) { - listOf(EMPTY) - } else { - data.listNotNull<String>("season").first().remove("Season ").split(' ') - } - - val seasonString = splitSeasonString.first().trim().lowercase() - val season = when(seasonString) { - "winter" -> WINTER - "spring" -> SPRING - "summer" -> SUMMER - "fall" -> FALL - else -> UNDEFINED - } - - val year = if (splitSeasonString.size == 2) { - YEAR_REGEX.find(splitSeasonString[1])?.value?.trim()?.ifBlank { "0" }?.toIntOrNull() ?: 0 - } else { - YEAR_REGEX.find(data.stringOrDefault("year"))?.value?.trim()?.toIntOrNull() ?: 0 - } - - return AnimeSeason( - season = season, - year = year, - ) - } - - private fun extractSourcesEntry(jsonData: ExtractionResult, data: ExtractionResult): HashSet<URI> { - if (!data.notFound("sourceDiv")) { - return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("sourceDiv"))) - } - - if (jsonData.notFound("url")) { - val link = jsonData.string("url").trim().remove("www.").ifBlank { - data.string("sourceMeta").trim().remove("www.") - } - - return hashSetOf(URI(link)) - } - - throw IllegalStateException("Unable to extract source.") - } - - private fun extractSynonyms(jsonData: ExtractionResult): HashSet<Title> { - return jsonData.listNotNull<Title>("alternateName") - .map { StringEscapeUtils.unescapeHtml4(it) } - .toHashSet() - } - - private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { - return if (data.notFound("relatedAnime")) { - hashSetOf() - } else { - // livechart only shows the franchise without naming the kind of relation - data.listNotNull<String>("relatedAnime") - .map { it.remove("/anime/") } - .map { RelatedAnime(metaDataProviderConfig.buildAnimeLink(it)) } - .toHashSet() - } - } - - private fun extractTags(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { - val tags: HashSet<String> = if (jsonData.notFound("genre")) { - hashSetOf() - } else { - jsonData.listNotNull<Tag>("genre") - .map { it.trim().lowercase() } - .toHashSet() - } - - if (!data.notFound("tags")) { - data.listNotNull<Tag>("tags") - .map { it.trim().lowercase() } - .forEach { tags.add(it) } - } - - return tags - } - - private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { - if (jsonldData.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = jsonldData.double("score") - val from = jsonldData.doubleOrDefault("worstRating", 1.0) - val to = jsonldData.doubleOrDefault("bestRating", 10.0) - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = from..to, - ) - } - - private fun extractStudios(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { - val studios: HashSet<Studio> = if (jsonData.notFound("productionCompany")) { - hashSetOf() - } else { - jsonData.listNotNull<Studio>("productionCompany").toHashSet() - } - - if (!data.notFound("studios")) { - data.listNotNull<Studio>("studios") - .map { it.normalize().lowercase() } - .filterNot { it == "studios" } - .forEach { studios.add(it) } - } - - return studios - } - - public companion object { - private const val LARGE_PICTURE_INDICATOR = "large.jpg" - private const val SMALL_PICTURE_INDICATOR = "small.jpg" - private val YEAR_REGEX = """\d{4}""".toRegex() - - /** - * Singleton of [LivechartAnimeConverter] - * @since 3.1.0 - */ - public val instance: LivechartAnimeConverter by lazy { LivechartAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.livechart + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.normalize +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.JsonDataExtractor +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import kotlinx.coroutines.withContext +import org.apache.commons.text.StringEscapeUtils +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property xmlExtractor Extractor which retrieves the data from raw data. + * @property jsonExtractor Extractor which retrieves the data from raw data. + */ +public class LivechartAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = LivechartConfig, + private val xmlExtractor: DataExtractor = XmlDataExtractor, + private val jsonExtractor: DataExtractor = JsonDataExtractor, +): AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = xmlExtractor.extract(rawContent, mapOf( + "jsonld" to "//script[@type='application/ld+json']/node()", + "title" to "//meta[@property='og:title']/@content", + "image" to "//meta[@property='og:image']/@content", + "episodesDiv" to "//div[text()='Episodes']/../text()", + "episodesCountdown" to "//div[@data-controller='countdown-bar']//div[contains(text(), 'EP')]/text()", + "type" to "//div[text()='Format']/..", + "status" to "//div[text()='Status']/..", + "duration" to "//div[text()='Run time']/..", + "season" to "//div[text()='Season']/../a/text()", + "year" to "//div[text()='Premiere']/following-sibling::*", + "relatedAnime" to "//div[@data-controller='carousel']//article/a/@href", + "tags" to "//div[text()='Tags']/..//a[@data-anime-details-target='tagChip']", + "sourceDiv" to "//div[@data-anime-details-id]/@data-anime-details-id", + "sourceMeta" to "//meta[@property='og:url']/@content", + "studios" to "//a[contains(@href, 'studios')]/text()", + )) + + val jsonld = data.listNotNull<String>("jsonld").first() + val jsonldData = jsonExtractor.extract(jsonld, mapOf( + "url" to "$.url", + "genre" to "$.genre", + "name" to "$.name", + "image" to "$.image", + "numberOfEpisodes" to "$.numberOfEpisodes", + "datePublished" to "$.datePublished", + "alternateName" to "$.alternateName", + "score" to "$.aggregateRating.ratingValue", + "worstRating" to "$.aggregateRating.worstRating", + "bestRating" to "$.aggregateRating.bestRating", + "productionCompany" to "$.productionCompany.*.name", + )) + + val picture = extractPicture(jsonldData, data) + + return@withContext AnimeRaw( + _title = extractTitle(jsonldData, data), + episodes = extractEpisodes(jsonldData, data), + type = extractType(data), + picture = picture, + thumbnail = findThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(jsonldData, data), + _synonyms = extractSynonyms(jsonldData), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(jsonldData, data), + _studios = extractStudios(jsonldData, data), + _producers = hashSetOf(), // not available on livechart + ).addScores(extractScore(jsonldData)) + } + + private fun extractTitle(jsonData: ExtractionResult, data: ExtractionResult): Title { + val name = jsonData.stringOrDefault("name").ifBlank { + data.stringOrDefault("title") + } + + return StringEscapeUtils.unescapeHtml4(name) + } + + private fun extractEpisodes(jsonData: ExtractionResult, data: ExtractionResult): Episodes { + // JSON data + var episodes = jsonData.intOrDefault("numberOfEpisodes") + var isTableValueUnknown = false + + // regular value in data table + if (episodes == 0) { + val episodesValue = data.stringOrDefault("episodesDiv", "?") + .trim() + .split('/') + .last() + + if (episodesValue != "?" && episodesValue != "-") { + episodes = episodesValue.toIntOrNull() ?: 0 + } else { + isTableValueUnknown = true + } + } + + // current episode from table if the anime is ongoing + if (episodes == 0) { + episodes = data.stringOrDefault("episodesCountdown") + .remove("EP") + .trim() + .toIntOrNull() + ?: 0 + + if (episodes != 0 ) { + episodes -= 1 + } + } + + return when { + episodes == 0 && isTableValueUnknown -> 1 + else -> episodes + } + } + + private fun extractType(data: ExtractionResult): AnimeType { + val value = data.string("type") + + return when(value.trim().lowercase()) { + "movie" -> MOVIE + "ova" -> OVA + "special" -> SPECIAL + "tv" -> TV + "tv short" -> TV + "tv special" -> SPECIAL + "web" -> ONA + "web short" -> ONA + "?" -> UNKNOWN_TYPE + "unknown" -> UNKNOWN_TYPE + else -> throw IllegalStateException("Unknown type [$value]") + } + } + + private fun extractPicture(jsonData: ExtractionResult, data: ExtractionResult): URI { + val value = jsonData.stringOrDefault("image").ifBlank { + data.stringOrDefault("image") + } + + return if (!value.endsWith(LARGE_PICTURE_INDICATOR)) { + NO_PICTURE + } else { + URI(value) + } + } + + private fun findThumbnail(uri: URI): URI { + return if (uri == NO_PICTURE) { + NO_PICTURE_THUMBNAIL + } else { + URI(uri.toString().replace(LARGE_PICTURE_INDICATOR, SMALL_PICTURE_INDICATOR)) + } + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val statusString = data.stringOrDefault("status") + + return when (statusString.trim().lowercase()){ + "not yet released" -> UPCOMING + "releasing" -> ONGOING + "finished" -> FINISHED + else -> UNKNOWN_STATUS + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + val durationString = data.stringOrDefault("duration").trim() + + val seconds = """(\d+ ?[aA-zZ]+)+""".toRegex() + .findAll(durationString) + .map { it.value } + .map { + val value = ("""\d+""".toRegex().find(it)?.value?.trim() ?: "0").ifBlank { "0" }.toIntOrNull() ?: 0 + val unit = """[a-z]+""".toRegex().find(it)?.value?.trim()?.lowercase() ?: "" + value to unit + } + .map { + when(it.second) { + "hr", "h" -> it.first * 3600 + "min", "m" -> it.first * 60 + "sec", "s" -> it.first + else -> throw IllegalStateException("Unknown unit [${it.second}]") + } + } + .sum() + + return Duration(seconds, SECONDS) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val splitSeasonString = if (data.notFound("season")) { + listOf(EMPTY) + } else { + data.listNotNull<String>("season").first().remove("Season ").split(' ') + } + + val seasonString = splitSeasonString.first().trim().lowercase() + val season = when(seasonString) { + "winter" -> WINTER + "spring" -> SPRING + "summer" -> SUMMER + "fall" -> FALL + else -> UNDEFINED + } + + val year = if (splitSeasonString.size == 2) { + YEAR_REGEX.find(splitSeasonString[1])?.value?.trim()?.ifBlank { "0" }?.toIntOrNull() ?: 0 + } else { + YEAR_REGEX.find(data.stringOrDefault("year"))?.value?.trim()?.toIntOrNull() ?: 0 + } + + return AnimeSeason( + season = season, + year = year, + ) + } + + private fun extractSourcesEntry(jsonData: ExtractionResult, data: ExtractionResult): HashSet<URI> { + if (!data.notFound("sourceDiv")) { + return hashSetOf(metaDataProviderConfig.buildAnimeLink(data.string("sourceDiv"))) + } + + if (jsonData.notFound("url")) { + val link = jsonData.string("url").trim().remove("www.").ifBlank { + data.string("sourceMeta").trim().remove("www.") + } + + return hashSetOf(URI(link)) + } + + throw IllegalStateException("Unable to extract source.") + } + + private fun extractSynonyms(jsonData: ExtractionResult): HashSet<Title> { + return jsonData.listNotNull<Title>("alternateName") + .map { StringEscapeUtils.unescapeHtml4(it) } + .toHashSet() + } + + private fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + return if (data.notFound("relatedAnime")) { + hashSetOf() + } else { + // livechart only shows the franchise without naming the kind of relation + data.listNotNull<String>("relatedAnime") + .map { it.remove("/anime/") } + .map { RelatedAnime(metaDataProviderConfig.buildAnimeLink(it)) } + .toHashSet() + } + } + + private fun extractTags(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { + val tags: HashSet<String> = if (jsonData.notFound("genre")) { + hashSetOf() + } else { + jsonData.listNotNull<Tag>("genre") + .map { it.trim().lowercase() } + .toHashSet() + } + + if (!data.notFound("tags")) { + data.listNotNull<Tag>("tags") + .map { it.trim().lowercase() } + .forEach { tags.add(it) } + } + + return tags + } + + private fun extractScore(jsonldData: ExtractionResult): MetaDataProviderScore { + if (jsonldData.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = jsonldData.double("score") + val from = jsonldData.doubleOrDefault("worstRating", 1.0) + val to = jsonldData.doubleOrDefault("bestRating", 10.0) + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = from..to, + ) + } + + private fun extractStudios(jsonData: ExtractionResult, data: ExtractionResult): HashSet<Tag> { + val studios: HashSet<Studio> = if (jsonData.notFound("productionCompany")) { + hashSetOf() + } else { + jsonData.listNotNull<Studio>("productionCompany").toHashSet() + } + + if (!data.notFound("studios")) { + data.listNotNull<Studio>("studios") + .map { it.normalize().lowercase() } + .filterNot { it == "studios" } + .forEach { studios.add(it) } + } + + return studios + } + + public companion object { + private const val LARGE_PICTURE_INDICATOR = "large.jpg" + private const val SMALL_PICTURE_INDICATOR = "small.jpg" + private val YEAR_REGEX = """\d{4}""".toRegex() + + /** + * Singleton of [LivechartAnimeConverter] + * @since 3.1.0 + */ + public val instance: LivechartAnimeConverter by lazy { LivechartAnimeConverter() } + } +} \ No newline at end of file diff --git a/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt b/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt index 2f15645b..6ce0f6d2 100644 --- a/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt +++ b/livechart/src/test/kotlin/io/github/manamiproject/modb/livechart/LivechartAnimeConverterTest.kt @@ -1,1405 +1,1405 @@ -package io.github.manamiproject.modb.livechart - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class LivechartAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Alice Gear Aegis: Doki! Actress Darake no Mermaid Grand Prix ♡") - } - } - - @Test - fun `title containing encoded special chars`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/encoded_special_char.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Love Live! School Idol Project: μ's →NEXT LoveLive! 2014 - Endless Parade Encore Animation") - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `no synonyms`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/no_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `one synonym`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/one_synonym.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly("劇場版 少女☆歌劇 レヴュースタァライト ロンド・ロンド・ロンド") - } - } - - @Test - fun `multiple synonyms`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/multiple_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Angels of Death", - "殺戮の天使", - ) - } - } - - @Test - fun `synonym containing encoded special char`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/encoded_special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly("ラブライブ! μ's Final LoveLive! オープニングアニメーション") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isZero() - } - } - - @Test - fun `1 episode`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/1.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/10.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/100.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `number episodes unknown, but currently running`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_unknown_but_currently_running.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1093) - } - } - - @Test - fun `number episodes known and currently running`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_known_and_running.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(12) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 3437`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/sources/3437.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://livechart.me/anime/3437")) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/large.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/small.jpg")) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is TV`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `Unknown is mapped to 'UNKNOWN'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `type 'tv short' is mapped to 'TV'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv_short.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is Movie`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/movie.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type 'web short' is mapped to 'ONA'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web_short.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type 'web' is mapped to 'ONA'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/ova.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is Special`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hr`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 hr 11 min`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour_11_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) - } - } - - @Test - fun `2 hr`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `2 hr 15 min`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours_15_minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) - } - } - - @Test - fun `10 min`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/10_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - - @Test - fun `30 sec`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/30_sec.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(30, SECONDS)) - } - } - - @Test - fun `unknown duration`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple tags`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/multiple_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "comedy", - "fantasy", - "mythology", - "romantic subtext", - "supernatural", - "workplace", - "sequel", - ) - } - } - - @Test - fun `extract exactly one tag`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/one_tag.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly("cgi") - } - } - - @Test - fun `no tags available`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/no_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @Test - fun `season is 'UNDEFINED'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/undefined.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'FALL'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/fall.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'SPRING'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/spring.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'SUMMER'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/summer.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'WINTER'`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/winter.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @Test - fun `season is 'UNDEFINED' because there is no element containing season info`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/no_season_element.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `season set completely`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_set.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `season is not set, but premiere`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_set.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `both season and premiere are not set`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_not_set.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isZero() - } - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `status is ongoing`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/ongoing.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `status is upcoming`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/no_yet_released.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `status is finished`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/finished.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no related anime`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/no_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `one related anime`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/one_relation.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(URI("https://livechart.me/anime/3808")), - ) - } - } - - @Test - fun `multiple related anime no scrolling`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_no_scrolling.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://livechart.me/anime/3367")), - RelatedAnime(URI("https://livechart.me/anime/3369")), - RelatedAnime(URI("https://livechart.me/anime/3368")), - ) - } - } - - @Test - fun `multiple related anime with scrolling`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_with_scrolling.html") - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://livechart.me/anime/3662")), - RelatedAnime(URI("https://livechart.me/anime/6221")), - RelatedAnime(URI("https://livechart.me/anime/3767")), - RelatedAnime(URI("https://livechart.me/anime/7353")), - RelatedAnime(URI("https://livechart.me/anime/3782")), - RelatedAnime(URI("https://livechart.me/anime/7133")), - RelatedAnime(URI("https://livechart.me/anime/4823")), - RelatedAnime(URI("https://livechart.me/anime/7289")), - RelatedAnime(URI("https://livechart.me/anime/6604")), - RelatedAnime(URI("https://livechart.me/anime/5530")), - RelatedAnime(URI("https://livechart.me/anime/3984")), - RelatedAnime(URI("https://livechart.me/anime/5793")), - RelatedAnime(URI("https://livechart.me/anime/7156")), - RelatedAnime(URI("https://livechart.me/anime/4054")), - RelatedAnime(URI("https://livechart.me/anime/6022")), - ) - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - // when - val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/score.html")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.66) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - // when - val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/no-score.html")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/multiple_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = LivechartConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() - } - - val converter = LivechartAnimeConverter( - metaDataProviderConfig = testLivechartConfig, - ) - - val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/no_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = LivechartAnimeConverter.instance - - // when - val result = LivechartAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(LivechartAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.livechart + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class LivechartAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Alice Gear Aegis: Doki! Actress Darake no Mermaid Grand Prix ♡") + } + } + + @Test + fun `title containing encoded special chars`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/title/encoded_special_char.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Love Live! School Idol Project: μ's →NEXT LoveLive! 2014 - Endless Parade Encore Animation") + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `no synonyms`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/no_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `one synonym`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/one_synonym.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly("劇場版 少女☆歌劇 レヴュースタァライト ロンド・ロンド・ロンド") + } + } + + @Test + fun `multiple synonyms`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/multiple_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Angels of Death", + "殺戮の天使", + ) + } + } + + @Test + fun `synonym containing encoded special char`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/synonyms/encoded_special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly("ラブライブ! μ's Final LoveLive! オープニングアニメーション") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isZero() + } + } + + @Test + fun `1 episode`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/1.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/10.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/100.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `number episodes unknown, but currently running`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_unknown_but_currently_running.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1093) + } + } + + @Test + fun `number episodes known and currently running`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/episodes/number_of_episodes_known_and_running.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(12) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 3437`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/sources/3437.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://livechart.me/anime/3437")) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/large.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://u.livechart.me/anime/3437/poster_image/ea9acd1ccea844fd9c4debde5e8e631e.png/small.jpg")) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is TV`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `Unknown is mapped to 'UNKNOWN'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `type 'tv short' is mapped to 'TV'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/tv_short.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is Movie`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/movie.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type 'web short' is mapped to 'ONA'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web_short.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type 'web' is mapped to 'ONA'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/web.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/ova.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is Special`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/type/special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hr`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 hr 11 min`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/1_hour_11_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) + } + } + + @Test + fun `2 hr`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `2 hr 15 min`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/2_hours_15_minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) + } + } + + @Test + fun `10 min`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/10_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + + @Test + fun `30 sec`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/30_sec.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(30, SECONDS)) + } + } + + @Test + fun `unknown duration`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/duration/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple tags`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/multiple_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "comedy", + "fantasy", + "mythology", + "romantic subtext", + "supernatural", + "workplace", + "sequel", + ) + } + } + + @Test + fun `extract exactly one tag`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/one_tag.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly("cgi") + } + } + + @Test + fun `no tags available`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/tags/no_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @Test + fun `season is 'UNDEFINED'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/undefined.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'FALL'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/fall.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'SPRING'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/spring.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'SUMMER'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/summer.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'WINTER'`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/winter.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @Test + fun `season is 'UNDEFINED' because there is no element containing season info`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/season/no_season_element.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `season set completely`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_set.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `season is not set, but premiere`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_set.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `both season and premiere are not set`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/anime_season/year_of_premiere/season_tba_premiere_not_set.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isZero() + } + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `status is ongoing`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/ongoing.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `status is upcoming`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/no_yet_released.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `status is finished`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/status/finished.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no related anime`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/no_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `one related anime`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/one_relation.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://livechart.me/anime/3808")), + ) + } + } + + @Test + fun `multiple related anime no scrolling`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_no_scrolling.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://livechart.me/anime/3367")), + RelatedAnime(URI("https://livechart.me/anime/3369")), + RelatedAnime(URI("https://livechart.me/anime/3368")), + ) + } + } + + @Test + fun `multiple related anime with scrolling`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/related_anime/multiple_relations_with_scrolling.html") + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://livechart.me/anime/3662")), + RelatedAnime(URI("https://livechart.me/anime/6221")), + RelatedAnime(URI("https://livechart.me/anime/3767")), + RelatedAnime(URI("https://livechart.me/anime/7353")), + RelatedAnime(URI("https://livechart.me/anime/3782")), + RelatedAnime(URI("https://livechart.me/anime/7133")), + RelatedAnime(URI("https://livechart.me/anime/4823")), + RelatedAnime(URI("https://livechart.me/anime/7289")), + RelatedAnime(URI("https://livechart.me/anime/6604")), + RelatedAnime(URI("https://livechart.me/anime/5530")), + RelatedAnime(URI("https://livechart.me/anime/3984")), + RelatedAnime(URI("https://livechart.me/anime/5793")), + RelatedAnime(URI("https://livechart.me/anime/7156")), + RelatedAnime(URI("https://livechart.me/anime/4054")), + RelatedAnime(URI("https://livechart.me/anime/6022")), + ) + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + // when + val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/score.html")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.66) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + // when + val result = converter.convert(loadTestResource("LivechartAnimeConverterTest/scores/no-score.html")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/multiple_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testLivechartConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = LivechartConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = LivechartConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = LivechartConfig.fileSuffix() + } + + val converter = LivechartAnimeConverter( + metaDataProviderConfig = testLivechartConfig, + ) + + val testFile = loadTestResource<String>("LivechartAnimeConverterTest/studios/no_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = LivechartAnimeConverter.instance + + // when + val result = LivechartAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(LivechartAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt b/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt index b1300eef..ce2a1938 100644 --- a/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt +++ b/myanimelist/src/main/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverter.kt @@ -1,323 +1,323 @@ -package io.github.manamiproject.modb.myanimelist - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.WRAP_TABLE_ROW -import io.github.manamiproject.modb.core.extractor.extractLabelledLinks -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import io.github.manamiproject.modb.core.logging.LoggerDelegate -import kotlinx.coroutines.withContext -import java.net.URI -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -/** - * Converts raw data to an [AnimeRaw]. - * Requires raw HTML from the mobile site version. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extracts specific data from raw content. - */ -public class MyanimelistAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = MyanimelistConfig, - private val extractor: DataExtractor = XmlDataExtractor, -) : AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract(rawContent, mapOf( - "title" to "//meta[@property='og:title']/@content", - "episodes" to "//td[text()='Episodes']/following-sibling::td/a/text()", - "source" to "//meta[@property='og:url']/@content", - "status" to "//td[text()='Status']/following-sibling::*/text()", - "tags" to "//span[@itemprop='genre']/text()", - "type" to "//td[text()='Type']/following-sibling::*/text()", - "duration" to "//td[text()='Duration']/following-sibling::*/text()", - "premiered" to "//td[text()='Premiered']/following-sibling::*/text()", - "aired" to "//td[text()='Aired']/following-sibling::*/text()", - "picture" to "//div[contains(@class, 'status-block')]/div[@itemprop='image']/@content", - "relatedAnime" to "//table[contains(@class, 'entries-table')]//tr/html()", - "relatedAnimeDetails" to "//div[contains(@class, 'anime-detail-related-entries')]/html()", - "synonyms" to "//h2[text()='Information']/following-sibling::*//tr[0]/td[1]", - "score" to "//span[@itemprop='ratingValue']/span/text()", - "studios" to "//td[text()='Studios']/following-sibling::td/a/text()", - "producers" to "//td[text()='Producers']/following-sibling::td/a/text()", - )) - - val picture = extractPicture(data) - val title = extractTitle(data) - - return@withContext AnimeRaw( - _title = title, - episodes = extractEpisodes(data), - type = extractType(data), - picture = picture, - thumbnail = findThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = postProcessSynonyms(title, extractSynonyms(data)), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<String> { - val processedSynonyms = hashSetOf<String>() - - when { - !title.contains(';') -> { - processedSynonyms.addAll( - synonyms.flatMap { it.split("; ") } - .map { it.remove(";") } - .map { it.trim() } - ) - } - title.contains("""[^ ];[^ ]""".toRegex()) or title.endsWith(";") -> { - processedSynonyms.addAll( - synonyms.flatMap { it.split("; ") } - .map { it.trim() } - ) - } - else -> processedSynonyms.addAll(synonyms.map { it.trim() }) - } - - return processedSynonyms - } - - private fun extractTitle(data: ExtractionResult) = data.string("title") - - private fun extractEpisodes(data: ExtractionResult): Int { - return if (data.notFound("episodes")) { - 0 - } else { - val matchResult = """\d+""".toRegex().find(data.string("episodes")) - return matchResult?.value?.toInt() ?: 0 - } - } - - private fun extractType(data: ExtractionResult): AnimeType { - return when(data.string("type").trim().lowercase()) { - "tv" -> TV - "unknown" -> UNKNOWN_TYPE - "movie" -> MOVIE - "ova" -> OVA - "ona" -> ONA - "special" -> SPECIAL - "music" -> SPECIAL - "pv" -> SPECIAL - "cm" -> SPECIAL - "tv special" -> SPECIAL - else -> throw IllegalStateException("Unknown type [${data.string("type")}]") - } - } - - private fun extractPicture(data: ExtractionResult): URI { - val text = data.string("picture").trim() - - return if (text in setOf("https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon-256.png", "https://cdn.myanimelist.net/images/qm_50.gif")) { - NO_PICTURE - } else { - URI(text) - } - } - - private fun findThumbnail(picture: URI): URI { - return if (NO_PICTURE != picture) { - URI(picture.toString().replace(".jpg", "t.jpg")) - } else { - NO_PICTURE_THUMBNAIL - } - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { - return if (data.notFound("synonyms")) { - hashSetOf() - } else { - data.listNotNull<Title>("synonyms").toHashSet() - } - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - val text = data.string("source") - val matchResult = """/\d+/""".toRegex().find(text) - val rawId = matchResult?.value ?: throw IllegalStateException("Unable to extract source") - val id = rawId.trimStart('/').trimEnd('/') - return hashSetOf(metaDataProviderConfig.buildAnimeLink(id)) - } - - private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { - // tiles: <div class="... anime-detail-related-entries"> ... <a href="..."> ... <div class="text">Sequel</div> - val relatedAnimeDetails = if (data.notFound("relatedAnimeDetails")) { - emptyList() - } else { - extractor.extractLabelledLinks( - snippets = data.listNotNull<String>("relatedAnimeDetails"), - labelSelector = "//div[@class='text']/text()", - linkSelector = "//a/@href", - ) - } - - // table: <tr><td class="... fn-grey5 ...">Side Story</td><td><ul class="entries"><li><a href="..."> - val relatedAnime = if (data.notFound("relatedAnime")) { - emptyList() - } else { - extractor.extractLabelledLinks( - snippets = data.listNotNull<String>("relatedAnime"), - labelSelector = "//td[contains(@class, 'fn-grey5')]/text()", - linkSelector = "//ul[contains(@class, 'entries')]//a/@href", - wrap = WRAP_TABLE_ROW, - ) - } - - return relatedAnime.union(relatedAnimeDetails) - .flatMap { entry -> entry.links.map { it to entry.label } } - .filter { (link, _) -> link.trim().startsWith(metaDataProviderConfig.buildAnimeLink(EMPTY).toString()) } - .mapNotNull { (link, label) -> - val id = """\d+""".toRegex().find(link)?.value ?: return@mapNotNull null - RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(id), label) - } - .toHashSet() - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - return when(data.string("status").trim().lowercase()) { - "finished airing" -> FINISHED - "currently airing" -> ONGOING - "not yet aired" -> UPCOMING - else -> throw IllegalStateException("Unknown status [${data.string("status")}]") - } - } - - private fun extractDuration(data: ExtractionResult): Duration { - if (data.notFound("duration")) { - return UNKNOWN_DURATION - } - - val text = data.string("duration").trim() - - val values = """\d+""".toRegex().findAll(text).toList().map { it.value } - val units = """(hr|min|sec)""".toRegex().findAll(text) - .toList() - .map { it.value } - .map { it.trim() } - .map { it.lowercase() } - - if (values.count() != units.count()) { - log.warn { "The amount of values [${values.count()}] does not match the amount of units [${units.count()}]." } - return Duration(0, SECONDS) - } - - val valueUnitPairs = mutableListOf<Pair<Int, String>>() - - for (index in values.indices) { - valueUnitPairs.add(Pair(values[index].toInt(), units[index])) - } - - var durationInSeconds = 0 - - valueUnitPairs.forEach { - durationInSeconds += when (it.second) { - "sec" -> it.first - "min" -> it.first * 60 - "hr" -> it.first * 3600 - else -> throw IllegalStateException("[${it.second} is an unknown unit.") - } - } - - return Duration(durationInSeconds, SECONDS) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - val premiered = data.string("premiered").trim() - val aired = data.string("aired").trim() - - val seasonText = """[aA-zZ]+""".toRegex().find(premiered)?.value ?: EMPTY - var season = Season.of(seasonText) - if (season == Season.UNDEFINED) { - season = when("""[aA-zZ]+""".toRegex().find(aired)?.value?.lowercase() ?: EMPTY) { - "jan", "feb", "mar" -> Season.WINTER - "apr", "may", "jun" -> Season.SPRING - "jul", "aug", "sep" -> Season.SUMMER - "oct", "nov", "dec" -> Season.FALL - else -> Season.UNDEFINED - } - } - - val yearPremiered = """\d{4}""".toRegex().find(premiered)?.value?.toInt() ?: 0 - val year = if (yearPremiered != 0) { - yearPremiered - } else { - """\d{4}""".toRegex().findAll(aired).firstOrNull()?.value?.toInt() ?: 0 - } - - return AnimeSeason( - season = season, - year = year - ) - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - return if (data.notFound("tags")) { - hashSetOf() - } else { - data.listNotNull<Tag>("tags") - .filterNot { it == data.string("title") } - .toHashSet() - } - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = 1.0..10.0, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return if (data.notFound("producers")) { - hashSetOf() - } else { - data.listNotNull<Producer>("producers").toHashSet() - } - } - - public companion object { - private val log by LoggerDelegate() - - /** - * Singleton of [MyanimelistAnimeConverter] - * @since 6.1.0 - */ - public val instance: MyanimelistAnimeConverter by lazy { MyanimelistAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.myanimelist + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.SECONDS +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.WRAP_TABLE_ROW +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import io.github.manamiproject.modb.core.logging.LoggerDelegate +import kotlinx.coroutines.withContext +import java.net.URI +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +/** + * Converts raw data to an [AnimeRaw]. + * Requires raw HTML from the mobile site version. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extracts specific data from raw content. + */ +public class MyanimelistAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = MyanimelistConfig, + private val extractor: DataExtractor = XmlDataExtractor, +) : AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract(rawContent, mapOf( + "title" to "//meta[@property='og:title']/@content", + "episodes" to "//td[text()='Episodes']/following-sibling::td/a/text()", + "source" to "//meta[@property='og:url']/@content", + "status" to "//td[text()='Status']/following-sibling::*/text()", + "tags" to "//span[@itemprop='genre']/text()", + "type" to "//td[text()='Type']/following-sibling::*/text()", + "duration" to "//td[text()='Duration']/following-sibling::*/text()", + "premiered" to "//td[text()='Premiered']/following-sibling::*/text()", + "aired" to "//td[text()='Aired']/following-sibling::*/text()", + "picture" to "//div[contains(@class, 'status-block')]/div[@itemprop='image']/@content", + "relatedAnime" to "//table[contains(@class, 'entries-table')]//tr/html()", + "relatedAnimeDetails" to "//div[contains(@class, 'anime-detail-related-entries')]/html()", + "synonyms" to "//h2[text()='Information']/following-sibling::*//tr[0]/td[1]", + "score" to "//span[@itemprop='ratingValue']/span/text()", + "studios" to "//td[text()='Studios']/following-sibling::td/a/text()", + "producers" to "//td[text()='Producers']/following-sibling::td/a/text()", + )) + + val picture = extractPicture(data) + val title = extractTitle(data) + + return@withContext AnimeRaw( + _title = title, + episodes = extractEpisodes(data), + type = extractType(data), + picture = picture, + thumbnail = findThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = postProcessSynonyms(title, extractSynonyms(data)), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<String> { + val processedSynonyms = hashSetOf<String>() + + when { + !title.contains(';') -> { + processedSynonyms.addAll( + synonyms.flatMap { it.split("; ") } + .map { it.remove(";") } + .map { it.trim() } + ) + } + title.contains("""[^ ];[^ ]""".toRegex()) or title.endsWith(";") -> { + processedSynonyms.addAll( + synonyms.flatMap { it.split("; ") } + .map { it.trim() } + ) + } + else -> processedSynonyms.addAll(synonyms.map { it.trim() }) + } + + return processedSynonyms + } + + private fun extractTitle(data: ExtractionResult) = data.string("title") + + private fun extractEpisodes(data: ExtractionResult): Int { + return if (data.notFound("episodes")) { + 0 + } else { + val matchResult = """\d+""".toRegex().find(data.string("episodes")) + return matchResult?.value?.toInt() ?: 0 + } + } + + private fun extractType(data: ExtractionResult): AnimeType { + return when(data.string("type").trim().lowercase()) { + "tv" -> TV + "unknown" -> UNKNOWN_TYPE + "movie" -> MOVIE + "ova" -> OVA + "ona" -> ONA + "special" -> SPECIAL + "music" -> SPECIAL + "pv" -> SPECIAL + "cm" -> SPECIAL + "tv special" -> SPECIAL + else -> throw IllegalStateException("Unknown type [${data.string("type")}]") + } + } + + private fun extractPicture(data: ExtractionResult): URI { + val text = data.string("picture").trim() + + return if (text in setOf("https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon-256.png", "https://cdn.myanimelist.net/images/qm_50.gif")) { + NO_PICTURE + } else { + URI(text) + } + } + + private fun findThumbnail(picture: URI): URI { + return if (NO_PICTURE != picture) { + URI(picture.toString().replace(".jpg", "t.jpg")) + } else { + NO_PICTURE_THUMBNAIL + } + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<Title> { + return if (data.notFound("synonyms")) { + hashSetOf() + } else { + data.listNotNull<Title>("synonyms").toHashSet() + } + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + val text = data.string("source") + val matchResult = """/\d+/""".toRegex().find(text) + val rawId = matchResult?.value ?: throw IllegalStateException("Unable to extract source") + val id = rawId.trimStart('/').trimEnd('/') + return hashSetOf(metaDataProviderConfig.buildAnimeLink(id)) + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + // tiles: <div class="... anime-detail-related-entries"> ... <a href="..."> ... <div class="text">Sequel</div> + val relatedAnimeDetails = if (data.notFound("relatedAnimeDetails")) { + emptyList() + } else { + extractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnimeDetails"), + labelSelector = "//div[@class='text']/text()", + linkSelector = "//a/@href", + ) + } + + // table: <tr><td class="... fn-grey5 ...">Side Story</td><td><ul class="entries"><li><a href="..."> + val relatedAnime = if (data.notFound("relatedAnime")) { + emptyList() + } else { + extractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnime"), + labelSelector = "//td[contains(@class, 'fn-grey5')]/text()", + linkSelector = "//ul[contains(@class, 'entries')]//a/@href", + wrap = WRAP_TABLE_ROW, + ) + } + + return relatedAnime.union(relatedAnimeDetails) + .flatMap { entry -> entry.links.map { it to entry.label } } + .filter { (link, _) -> link.trim().startsWith(metaDataProviderConfig.buildAnimeLink(EMPTY).toString()) } + .mapNotNull { (link, label) -> + val id = """\d+""".toRegex().find(link)?.value ?: return@mapNotNull null + RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(id), label) + } + .toHashSet() + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + return when(data.string("status").trim().lowercase()) { + "finished airing" -> FINISHED + "currently airing" -> ONGOING + "not yet aired" -> UPCOMING + else -> throw IllegalStateException("Unknown status [${data.string("status")}]") + } + } + + private fun extractDuration(data: ExtractionResult): Duration { + if (data.notFound("duration")) { + return UNKNOWN_DURATION + } + + val text = data.string("duration").trim() + + val values = """\d+""".toRegex().findAll(text).toList().map { it.value } + val units = """(hr|min|sec)""".toRegex().findAll(text) + .toList() + .map { it.value } + .map { it.trim() } + .map { it.lowercase() } + + if (values.count() != units.count()) { + log.warn { "The amount of values [${values.count()}] does not match the amount of units [${units.count()}]." } + return Duration(0, SECONDS) + } + + val valueUnitPairs = mutableListOf<Pair<Int, String>>() + + for (index in values.indices) { + valueUnitPairs.add(Pair(values[index].toInt(), units[index])) + } + + var durationInSeconds = 0 + + valueUnitPairs.forEach { + durationInSeconds += when (it.second) { + "sec" -> it.first + "min" -> it.first * 60 + "hr" -> it.first * 3600 + else -> throw IllegalStateException("[${it.second} is an unknown unit.") + } + } + + return Duration(durationInSeconds, SECONDS) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + val premiered = data.string("premiered").trim() + val aired = data.string("aired").trim() + + val seasonText = """[aA-zZ]+""".toRegex().find(premiered)?.value ?: EMPTY + var season = Season.of(seasonText) + if (season == Season.UNDEFINED) { + season = when("""[aA-zZ]+""".toRegex().find(aired)?.value?.lowercase() ?: EMPTY) { + "jan", "feb", "mar" -> Season.WINTER + "apr", "may", "jun" -> Season.SPRING + "jul", "aug", "sep" -> Season.SUMMER + "oct", "nov", "dec" -> Season.FALL + else -> Season.UNDEFINED + } + } + + val yearPremiered = """\d{4}""".toRegex().find(premiered)?.value?.toInt() ?: 0 + val year = if (yearPremiered != 0) { + yearPremiered + } else { + """\d{4}""".toRegex().findAll(aired).firstOrNull()?.value?.toInt() ?: 0 + } + + return AnimeSeason( + season = season, + year = year + ) + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + return if (data.notFound("tags")) { + hashSetOf() + } else { + data.listNotNull<Tag>("tags") + .filterNot { it == data.string("title") } + .toHashSet() + } + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = 1.0..10.0, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return if (data.notFound("producers")) { + hashSetOf() + } else { + data.listNotNull<Producer>("producers").toHashSet() + } + } + + public companion object { + private val log by LoggerDelegate() + + /** + * Singleton of [MyanimelistAnimeConverter] + * @since 6.1.0 + */ + public val instance: MyanimelistAnimeConverter by lazy { MyanimelistAnimeConverter() } + } +} \ No newline at end of file diff --git a/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt b/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt index a5ba68a4..49733b96 100644 --- a/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt +++ b/myanimelist/src/test/kotlin/io/github/manamiproject/modb/myanimelist/MyanimelistAnimeConverterTest.kt @@ -1,1956 +1,1956 @@ -package io.github.manamiproject.modb.myanimelist - -import io.github.manamiproject.modb.core.anime.RelatedAnime -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import kotlin.test.Test -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE - -internal class MyanimelistAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `title containing special chars`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/special_chars.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") - } - } - - @Test - fun `anime has original and english title in header`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/english_and_original_title.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Rurouni Kenshin: Meiji Kenkaku Romantan") - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is TV`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `'Unknown' is mapped to 'UNKNOWN'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(UNKNOWN_TYPE) - } - } - - @Test - fun `type is Movie`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is music is mapped to special`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type is ONA`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ona.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ova.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is Special`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type PV is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/pv.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type CM is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/cm.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type 'TV Special' is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv_special.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `type Music but without a link is mapped to SPECIAL`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music_without_link.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `movie case which resulted in containsOwn`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie_case_which_resulted_in_containsOwn.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(0) - } - } - - @Test - fun `1 episode`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/10.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/100.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1818.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) - assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551.jpg")) - assertThat(result.thumbnail).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551t.jpg")) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `no synonyms`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/no_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - - @Test - fun `one synonym`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly("エスパー魔美スペシャル マイエンジェル魔美ちゃん") - } - } - - @Test - fun `multiple languages, one synonym each`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_languages_one_each.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Prétear", - "Prétear: The New Legend of Snow White", - "Shin Shirayuki-hime Densetsu Pretear", - "新白雪姫伝説プリーティア", - ) - } - } - - @Test - fun `synonym containing comma`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/synonym_contains_comma_but_title_does_not.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "'Tis Time for \"Torture,\" Princess", - "It's Time for \"Interrogation\", Princess!", - "Hime-sama \"Goumon\" no Jikan desu", - "姫様“拷問”の時間です", - ) - } - } - - @Test - fun `multiple synonyms for one language`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_for_one_language.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "DB Kai", - "DBK", - "DBZ Kai", - "Dragon Ball Z Kai: The Final Chapters", - "Dragonball Kai", - "ドラゴンボール改", - ) - } - } - - @Test - fun `multiple synonyms with comma in one language section`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_with_comma_in_one_language.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Kono Naka ni Hitori, Imouto ga Iru! Episode 13", - "Kono Naka ni Hitori, Imouto ga Iru! OVA", - "NAKAIMO - My Little Sister Is Among Them! OVA", - "NakaImo, One of Them is My Younger Sister! OVA", - "Who is Imouto? OVA", - "この中に1人、妹がいる!兄、妹、恋人", - ) - } - } - - @Test - fun `one synonym with multiple commas`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym_with_multiple_commas.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Yuru Yuri Special", - "YuruYuri: For Whatever Reason, Never Ending, Heart Throbbing, Pitter-patter, Paradox Eternal", - "ゆるゆり どうして☆止まらない☆トキメキ☆ドキドキ☆パラドクス☆エターナル", - ) - } - } - - @Test - fun `semicolon in synonym wihtout whitespaces`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_wihtout_whitespaces.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "CHAOS;CHILD SILENT SKY", - "Chaos Child Episode 13", - "Chaos Child Episode 14", - "Chaos Child: Silent Sky", - "Chaos;Child, Folge 13 & 14 Silent Sky", - "ChäoS;Child: Silent Sky. Episodios 13 y 14", - "ChäoS;Child: Épisode 13 und 14 Silent Sky", - ) - } - } - - @Test - fun `semicolon in synonym with whitespace`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_with_whitespace.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactly( - "Fate/Grand Order: Divine Realm of the Round Table - Camelot Wandering; Agateram", - "劇場版Fate/Grand Order -神聖円卓領域キャメロット- Wandering; Agateram", - ) - } - } - - @Test - fun `semicolon in title but not in synonyms`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_title_but_not_in_synonyms.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "Dual Parallel! Trouble Adventures - Final Frontier", - "Dual! Parallel Lunlun Monogatari Special", - "Dual! Parallel Trouble Adventures Special", - "デュアル!ぱられルンルン物語「ファイナル・フロンティア」", - ) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `extract id 16498`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/sources/16498.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactly(URI("https://myanimelist.net/anime/16498")) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `no adaption, no relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_no_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - - @Test - fun `no adaption, multiple relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_multiple_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://myanimelist.net/anime/17819"), "Other"), - RelatedAnime(URI("https://myanimelist.net/anime/19671"), "Side Story"), - RelatedAnime(URI("https://myanimelist.net/anime/22265"), "Sequel"), - RelatedAnime(URI("https://myanimelist.net/anime/30415"), "Prequel"), - RelatedAnime(URI("https://myanimelist.net/anime/33845"), "Alternative Version"), - ) - } - } - - @Test - fun `one adaption, one relation`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactly( - RelatedAnime(URI("https://myanimelist.net/anime/10165"), "Sequel"), - ) - } - } - - @Test - fun `has adaption, multiple relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://myanimelist.net/anime/12685"), "Spin-Off"), - RelatedAnime(URI("https://myanimelist.net/anime/15197"), "Alternative Setting"), - RelatedAnime(URI("https://myanimelist.net/anime/15199"), "Alternative Setting"), - RelatedAnime(URI("https://myanimelist.net/anime/15201"), "Alternative Setting"), - RelatedAnime(URI("https://myanimelist.net/anime/17277"), "Alternative Setting"), - RelatedAnime(URI("https://myanimelist.net/anime/1953"), "Side Story"), - RelatedAnime(URI("https://myanimelist.net/anime/2124"), "Summary"), - RelatedAnime(URI("https://myanimelist.net/anime/2904"), "Sequel"), - RelatedAnime(URI("https://myanimelist.net/anime/30711"), "Alternative Setting"), - RelatedAnime(URI("https://myanimelist.net/anime/33155"), "Side Story"), - RelatedAnime(URI("https://myanimelist.net/anime/34438"), "Alternative Version"), - RelatedAnime(URI("https://myanimelist.net/anime/34439"), "Alternative Version"), - RelatedAnime(URI("https://myanimelist.net/anime/40334"), "Side Story"), - RelatedAnime(URI("https://myanimelist.net/anime/40836"), "Side Story"), - RelatedAnime(URI("https://myanimelist.net/anime/41075"), "Side Story"), - RelatedAnime(URI("https://myanimelist.net/anime/4596"), "Summary"), - RelatedAnime(URI("https://myanimelist.net/anime/8888"), "Alternative Setting"), - ) - } - } - - @Test - fun `has adaption, no relations`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_but_no_relation.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).isEmpty() - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `'currently airing' is mapped to 'ONGOING'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/ongoing.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `'Not yet aired' is mapped to 'UPCOMING'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/upcoming.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `'Finished Airing' is mapped to 'FINISHED'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/finished.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `extract multiple tags`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/multiple_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "action", - "adventure", - "drama", - "fantasy", - "military", - "shounen", - ) - } - } - - @Test - fun `extract exactly one tag`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/one_tag.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactly("fantasy") - } - } - - @Test - fun `no tags available`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/no_tags.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).isEmpty() - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `1 hr`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(1, HOURS)) - } - } - - @Test - fun `1 hr 11 min`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) - } - } - - @Test - fun `1 hr 11 min per ep`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min_per_episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) - } - } - - @Test - fun `2 hr`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(2, HOURS)) - } - } - - @Test - fun `2 hr 15 min`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours_15_minutes.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) - } - } - - @Test - fun `10 min`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - - @Test - fun `10 min per ep`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min_per_episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) - } - } - - @Test - fun `10 sec`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) - } - } - - @Test - fun `10 sec per ep`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec_per_episode.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) - } - } - - @Test - fun `unknown duration`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @Test - fun `'UNDEFINED', because anime season link is not available`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/undefined.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - } - } - - @Test - fun `season is 'FALL'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/fall.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @Test - fun `season is 'SPRING'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/spring.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @Test - fun `season is 'SUMMER'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/summer.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @Test - fun `season is 'WINTER'`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/winter.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["apr", "may", "jun"]) - fun `season is 'SPRING' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(strings = ["jul", "aug", "sep"]) - fun `season is 'SUMMER' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(strings = ["oct", "nov", "dec"]) - fun `season is 'FALL' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - - @ParameterizedTest - @ValueSource(strings = ["jan", "feb", "mar"]) - fun `season is 'WINTER' by aired because premiered is not set`(value: String) { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `extract from 'aired', because anime season is not set - year only`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_only.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1928) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - year to unknown`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1996) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - unavailable`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_unavailable.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(0) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day to unknown`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2004) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - year to year`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1964) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - month of year to year`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1992) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - month of year to unknown`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_unknown.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1996) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1980) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day to exact day`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_exact_day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1998) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - year to exact day`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_exact_day.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1977) - } - } - - @Test - fun `extract from 'aired', because anime season is not set - exact day to year`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_year.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(1982) - } - } - - @Test - fun `extract from anime season link which exists in mobile version, but not on desktop version`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/premiered.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2010) - } - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully load score`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - // when - val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/score.html")) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.62) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - // when - val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/no-score.html")) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/multiple_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/no_studios.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/multiple_producers.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "movic", - "magic capsule", - "crunchyroll", - "north stars pictures", - "jy animation", - "ca-cygames anime fund", - "mixer", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = MyanimelistConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() - } - - val converter = MyanimelistAnimeConverter(testMyanimelistConfig) - - val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/no_producers.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = MyanimelistAnimeConverter.instance - - // when - val result = MyanimelistAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(MyanimelistAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.myanimelist + +import io.github.manamiproject.modb.core.anime.RelatedAnime +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import kotlin.test.Test +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE + +internal class MyanimelistAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `title containing special chars`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/special_chars.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Tobidasu PriPara: Mi~nna de Mezase! Idol☆Grand Prix") + } + } + + @Test + fun `anime has original and english title in header`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/title/english_and_original_title.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Rurouni Kenshin: Meiji Kenkaku Romantan") + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is TV`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `'Unknown' is mapped to 'UNKNOWN'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(UNKNOWN_TYPE) + } + } + + @Test + fun `type is Movie`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is music is mapped to special`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type is ONA`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ona.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/ova.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is Special`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type PV is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/pv.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type CM is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/cm.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type 'TV Special' is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/tv_special.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `type Music but without a link is mapped to SPECIAL`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/music_without_link.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `movie case which resulted in containsOwn`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/type/movie_case_which_resulted_in_containsOwn.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(0) + } + } + + @Test + fun `1 episode`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/10.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/100.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/episodes/1818.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png")) + assertThat(result.thumbnail).isEqualTo(URI("https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png")) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551.jpg")) + assertThat(result.thumbnail).isEqualTo(URI("https://cdn.myanimelist.net/images/anime/5/50551t.jpg")) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `no synonyms`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/no_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + + @Test + fun `one synonym`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly("エスパー魔美スペシャル マイエンジェル魔美ちゃん") + } + } + + @Test + fun `multiple languages, one synonym each`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_languages_one_each.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Prétear", + "Prétear: The New Legend of Snow White", + "Shin Shirayuki-hime Densetsu Pretear", + "新白雪姫伝説プリーティア", + ) + } + } + + @Test + fun `synonym containing comma`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/synonym_contains_comma_but_title_does_not.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "'Tis Time for \"Torture,\" Princess", + "It's Time for \"Interrogation\", Princess!", + "Hime-sama \"Goumon\" no Jikan desu", + "姫様“拷問”の時間です", + ) + } + } + + @Test + fun `multiple synonyms for one language`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_for_one_language.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "DB Kai", + "DBK", + "DBZ Kai", + "Dragon Ball Z Kai: The Final Chapters", + "Dragonball Kai", + "ドラゴンボール改", + ) + } + } + + @Test + fun `multiple synonyms with comma in one language section`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/multiple_synonyms_with_comma_in_one_language.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Kono Naka ni Hitori, Imouto ga Iru! Episode 13", + "Kono Naka ni Hitori, Imouto ga Iru! OVA", + "NAKAIMO - My Little Sister Is Among Them! OVA", + "NakaImo, One of Them is My Younger Sister! OVA", + "Who is Imouto? OVA", + "この中に1人、妹がいる!兄、妹、恋人", + ) + } + } + + @Test + fun `one synonym with multiple commas`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/one_synonym_with_multiple_commas.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Yuru Yuri Special", + "YuruYuri: For Whatever Reason, Never Ending, Heart Throbbing, Pitter-patter, Paradox Eternal", + "ゆるゆり どうして☆止まらない☆トキメキ☆ドキドキ☆パラドクス☆エターナル", + ) + } + } + + @Test + fun `semicolon in synonym wihtout whitespaces`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_wihtout_whitespaces.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "CHAOS;CHILD SILENT SKY", + "Chaos Child Episode 13", + "Chaos Child Episode 14", + "Chaos Child: Silent Sky", + "Chaos;Child, Folge 13 & 14 Silent Sky", + "ChäoS;Child: Silent Sky. Episodios 13 y 14", + "ChäoS;Child: Épisode 13 und 14 Silent Sky", + ) + } + } + + @Test + fun `semicolon in synonym with whitespace`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_synonym_with_whitespace.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactly( + "Fate/Grand Order: Divine Realm of the Round Table - Camelot Wandering; Agateram", + "劇場版Fate/Grand Order -神聖円卓領域キャメロット- Wandering; Agateram", + ) + } + } + + @Test + fun `semicolon in title but not in synonyms`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/synonyms/semicolon_in_title_but_not_in_synonyms.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "Dual Parallel! Trouble Adventures - Final Frontier", + "Dual! Parallel Lunlun Monogatari Special", + "Dual! Parallel Trouble Adventures Special", + "デュアル!ぱられルンルン物語「ファイナル・フロンティア」", + ) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `extract id 16498`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/sources/16498.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactly(URI("https://myanimelist.net/anime/16498")) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `no adaption, no relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_no_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + + @Test + fun `no adaption, multiple relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/no_adaption_multiple_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://myanimelist.net/anime/17819"), "Other"), + RelatedAnime(URI("https://myanimelist.net/anime/19671"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/22265"), "Sequel"), + RelatedAnime(URI("https://myanimelist.net/anime/30415"), "Prequel"), + RelatedAnime(URI("https://myanimelist.net/anime/33845"), "Alternative Version"), + ) + } + } + + @Test + fun `one adaption, one relation`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_one_adaption_and_one_relation.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactly( + RelatedAnime(URI("https://myanimelist.net/anime/10165"), "Sequel"), + ) + } + } + + @Test + fun `has adaption, multiple relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_and_multiple_relations.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://myanimelist.net/anime/12685"), "Spin-Off"), + RelatedAnime(URI("https://myanimelist.net/anime/15197"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/15199"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/15201"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/17277"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/1953"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/2124"), "Summary"), + RelatedAnime(URI("https://myanimelist.net/anime/2904"), "Sequel"), + RelatedAnime(URI("https://myanimelist.net/anime/30711"), "Alternative Setting"), + RelatedAnime(URI("https://myanimelist.net/anime/33155"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/34438"), "Alternative Version"), + RelatedAnime(URI("https://myanimelist.net/anime/34439"), "Alternative Version"), + RelatedAnime(URI("https://myanimelist.net/anime/40334"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/40836"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/41075"), "Side Story"), + RelatedAnime(URI("https://myanimelist.net/anime/4596"), "Summary"), + RelatedAnime(URI("https://myanimelist.net/anime/8888"), "Alternative Setting"), + ) + } + } + + @Test + fun `has adaption, no relations`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/related_anime/has_adaption_but_no_relation.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).isEmpty() + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `'currently airing' is mapped to 'ONGOING'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/ongoing.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `'Not yet aired' is mapped to 'UPCOMING'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/upcoming.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `'Finished Airing' is mapped to 'FINISHED'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/status/finished.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `extract multiple tags`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/multiple_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "action", + "adventure", + "drama", + "fantasy", + "military", + "shounen", + ) + } + } + + @Test + fun `extract exactly one tag`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/one_tag.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactly("fantasy") + } + } + + @Test + fun `no tags available`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/tags/no_tags.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).isEmpty() + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `1 hr`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(1, HOURS)) + } + } + + @Test + fun `1 hr 11 min`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) + } + } + + @Test + fun `1 hr 11 min per ep`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/1_hour_11_min_per_episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(71, MINUTES)) + } + } + + @Test + fun `2 hr`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(2, HOURS)) + } + } + + @Test + fun `2 hr 15 min`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/2_hours_15_minutes.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(135, MINUTES)) + } + } + + @Test + fun `10 min`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + + @Test + fun `10 min per ep`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_min_per_episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, MINUTES)) + } + } + + @Test + fun `10 sec`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) + } + } + + @Test + fun `10 sec per ep`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/10_sec_per_episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(10, SECONDS)) + } + } + + @Test + fun `unknown duration`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/duration/unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration(0, SECONDS)) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @Test + fun `'UNDEFINED', because anime season link is not available`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/undefined.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + } + } + + @Test + fun `season is 'FALL'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/fall.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @Test + fun `season is 'SPRING'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/spring.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @Test + fun `season is 'SUMMER'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/summer.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @Test + fun `season is 'WINTER'`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/winter.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["apr", "may", "jun"]) + fun `season is 'SPRING' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(strings = ["jul", "aug", "sep"]) + fun `season is 'SUMMER' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(strings = ["oct", "nov", "dec"]) + fun `season is 'FALL' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + + @ParameterizedTest + @ValueSource(strings = ["jan", "feb", "mar"]) + fun `season is 'WINTER' by aired because premiered is not set`(value: String) { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/season/$value.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `extract from 'aired', because anime season is not set - year only`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_only.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1928) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - year to unknown`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1996) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - unavailable`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_unavailable.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(0) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day to unknown`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2004) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - year to year`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1964) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - month of year to year`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1992) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - month of year to unknown`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_month_of_year_to_unknown.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1996) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1980) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day to exact day`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_exact_day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1998) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - year to exact day`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_year_to_exact_day.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1977) + } + } + + @Test + fun `extract from 'aired', because anime season is not set - exact day to year`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/aired_node_-_exact_day_to_year.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(1982) + } + } + + @Test + fun `extract from anime season link which exists in mobile version, but not on desktop version`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/anime_season/year_of_premiere/premiered.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2010) + } + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully load score`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + // when + val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/score.html")) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.62) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + // when + val result = converter.convert(loadTestResource("MyanimelistAnimeConverterTest/scores/no-score.html")) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/multiple_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/studios/no_studios.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/multiple_producers.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "movic", + "magic capsule", + "crunchyroll", + "north stars pictures", + "jy animation", + "ca-cygames anime fund", + "mixer", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testMyanimelistConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = MyanimelistConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = MyanimelistConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = MyanimelistConfig.fileSuffix() + } + + val converter = MyanimelistAnimeConverter(testMyanimelistConfig) + + val testFile = loadTestResource<String>("MyanimelistAnimeConverterTest/producers/no_producers.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = MyanimelistAnimeConverter.instance + + // when + val result = MyanimelistAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(MyanimelistAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file diff --git a/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt b/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt index 2e417fd7..547b18fa 100644 --- a/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt +++ b/serde/src/test/kotlin/io/github/manamiproject/modb/serde/TestingAssets.kt @@ -1,1124 +1,1124 @@ -package io.github.manamiproject.modb.serde - -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL -import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED -import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL -import io.github.manamiproject.modb.core.anime.AnimeType.TV -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES -import io.github.manamiproject.modb.core.httpclient.HttpClient -import io.github.manamiproject.modb.core.httpclient.HttpResponse -import io.github.manamiproject.modb.core.httpclient.RequestBody -import io.github.manamiproject.modb.core.httpclient.RetryCase -import io.github.manamiproject.modb.serde.json.deserializer.Deserializer -import io.github.manamiproject.modb.test.shouldNotBeInvoked -import java.io.IOException -import java.io.InputStream -import java.net.URI -import java.net.URL -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION - -internal object TestHttpClient: HttpClient { - override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() - override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() -} - -internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { - override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() -} - -internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { - private var closed = false - - override fun read(): Int { - if (closed) throw IOException("Stream closed") - return delegate.read() - } - - override fun close() { - closed = true - delegate.close() - } -} - -@Suppress("unused") -internal object TestAnimeRawObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: AnimeRaw - get() = AnimeRaw("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "scores": [], - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Death Note", - _sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - _synonyms = hashSetOf( - "DN", - "デスノート", - ), - _studios = hashSetOf( - "madhouse", - ), - _producers = hashSetOf( - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - _tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "scores": [ - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "d.n. dream partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "Go-toubun no Hanayome *", - _sources = hashSetOf( - URI("https://anilist.co/anime/177191"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anilist.co/anime/131520")), - ), - _tags = hashSetOf( - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anilist.co/anime/177191" - ], - "title": "Go-toubun no Hanayome *", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - } - ], - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://anilist.co/anime/131520" - } - ], - "tags": [ - "comedy", - "drama", - "ensemble cast", - "female harem", - "heterosexual", - "language barrier", - "male protagonist", - "marriage", - "primarily female cast", - "romance", - "school", - "shounen", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: AnimeRaw - get() = AnimeRaw( - _title = "5-toubun no Hanayome*", - _sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - _synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - _studios = hashSetOf( - "bibury animation studios", - ), - _producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - _relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/16165")), - RelatedAnime(URI("https://anilist.co/anime/131520")), - RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), - RelatedAnime(URI("https://animecountdown.com/1577789")), - RelatedAnime(URI("https://anisearch.com/anime/16091")), - RelatedAnime(URI("https://kitsu.app/anime/44229")), - RelatedAnime(URI("https://livechart.me/anime/10488")), - RelatedAnime(URI("https://livechart.me/anime/11921")), - RelatedAnime(URI("https://livechart.me/anime/3448")), - RelatedAnime(URI("https://livechart.me/anime/9428")), - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - RelatedAnime(URI("https://simkl.com/anime/1577789")), - ), - _tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ).apply { - addScores( - MetaDataProviderScoreValue( - hostname = "anidb.net", - value = 6.63, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "anilist.co", - value = 75.0, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "anime-planet.com", - value = 3.845, - range = 0.5..5.0, - ), - MetaDataProviderScoreValue( - hostname = "anisearch.com", - value = 3.9, - range = 0.1..5.0, - ), - MetaDataProviderScoreValue( - hostname = "kitsu.app", - value = 76.95, - range = 1.0..100.0, - ), - MetaDataProviderScoreValue( - hostname = "livechart.me", - value = 8.35, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "myanimelist.net", - value = 8.62, - range = 1.0..10.0, - ), - MetaDataProviderScoreValue( - hostname = "simkl.com", - value = 7.9, - range = 1.0..10.0, - ), - ) - } - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "scores": [ - { - "hostname": "anidb.net", - "value": 6.63, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "anilist.co", - "value": 75.0, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "anime-planet.com", - "value": 3.845, - "range": { - "minInclusive": 0.5, - "maxInclusive": 5.0 - } - }, - { - "hostname": "anisearch.com", - "value": 3.9, - "range": { - "minInclusive": 0.1, - "maxInclusive": 5.0 - } - }, - { - "hostname": "kitsu.app", - "value": 76.95, - "range": { - "minInclusive": 1.0, - "maxInclusive": 100.0 - } - }, - { - "hostname": "livechart.me", - "value": 8.35, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "myanimelist.net", - "value": 8.62, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - }, - { - "hostname": "simkl.com", - "value": 7.9, - "range": { - "minInclusive": 1.0, - "maxInclusive": 10.0 - } - } - ], - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/16165" - }, - { - "uri": "https://anilist.co/anime/131520" - }, - { - "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" - }, - { - "uri": "https://animecountdown.com/1577789" - }, - { - "uri": "https://anisearch.com/anime/16091" - }, - { - "uri": "https://kitsu.app/anime/44229" - }, - { - "uri": "https://livechart.me/anime/10488" - }, - { - "uri": "https://livechart.me/anime/11921" - }, - { - "uri": "https://livechart.me/anime/3448" - }, - { - "uri": "https://livechart.me/anime/9428" - }, - { - "uri": "https://myanimelist.net/anime/48548" - }, - { - "uri": "https://simkl.com/anime/1577789" - } - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} - -@Suppress("unused") -internal object TestAnimeObjects { - - /** - * Default anime. Only default values are set. - */ - object DefaultAnime { - - val obj: Anime - get() = Anime("default") - - val serializedPrettyPrint = """ - { - "sources": [], - "title": "default", - "type": "UNKNOWN", - "episodes": 0, - "status": "UNKNOWN", - "animeSeason": { - "season": "UNDEFINED", - "year": null - }, - "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", - "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", - "duration": null, - "score": null, - "synonyms": [], - "studios": [], - "producers": [], - "relatedAnime": [], - "tags": [] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} - """.trimIndent() - } - - /** - * Default anime of type TV with all optional fields not set to enforce `null` in serialization. - */ - object NullableNotSet { - - val obj: Anime - get() = Anime( - title = "Death Note", - sources = hashSetOf( - URI("https://myanimelist.net/anime/1535"), - ), - type = TV, - episodes = 37, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = UNKNOWN_YEAR, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), - duration = UNKNOWN_DURATION, - score = NoScore, - synonyms = hashSetOf( - "DN", - "デスノート", - ), - studios = hashSetOf( - "madhouse", - ), - producers = hashSetOf( - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/2994")), - ), - tags = hashSetOf( - "psychological", - "shounen", - "supernatural", - "suspense", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/1535" - ], - "title": "Death Note", - "type": "TV", - "episodes": 37, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": null - }, - "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", - "duration": null, - "score": null, - "synonyms": [ - "DN", - "デスノート" - ], - "studios": [ - "madhouse" - ], - "producers": [ - "D.N. Dream Partners", - "nippon television network", - "shueisha", - "vap" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/2994" - } - ], - "tags": [ - "psychological", - "shounen", - "supernatural", - "suspense" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Single metadata provider only. - */ - object AllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://myanimelist.net/anime/58755"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.44, - arithmeticMean = 7.44, - median = 7.44, - ), - synonyms = hashSetOf( - "The Quintessential Quintuplets*", - "五等分の花嫁*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - ), - tags = hashSetOf( - "comedy", - "harem", - "romance", - "school", - "shounen", - ), - ) - - val serializedPrettyPrint = """ - { - "sources": [ - "https://myanimelist.net/anime/58755" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.44, - "arithmeticMean": 7.44, - "median": 7.44 - }, - "synonyms": [ - "The Quintessential Quintuplets*", - "五等分の花嫁*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon" - ], - "relatedAnime": [ - { - "uri": "https://myanimelist.net/anime/48548" - } - ], - "tags": [ - "comedy", - "harem", - "romance", - "school", - "shounen" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} - """.trimIndent() - } - - /** - * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. - */ - object FullyMergedAllPropertiesSet { - - val obj: Anime - get() = Anime( - title = "5-toubun no Hanayome*", - sources = hashSetOf( - URI("https://anidb.net/anime/18603"), - URI("https://anilist.co/anime/177191"), - URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), - URI("https://anisearch.com/anime/19242"), - URI("https://kitsu.app/anime/48807"), - URI("https://livechart.me/anime/12646"), - URI("https://myanimelist.net/anime/58755"), - URI("https://simkl.com/anime/2448488"), - ), - type = SPECIAL, - episodes = 2, - status = FINISHED, - animeSeason = AnimeSeason( - season = FALL, - year = 2024, - ), - picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), - thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), - duration = Duration( - value = 24, - unit = MINUTES, - ), - score = ScoreValue( - arithmeticGeometricMean = 7.616175305477198, - arithmeticMean = 7.624601113172541, - median = 7.7272727272727275, - ), - synonyms = hashSetOf( - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*", - ), - studios = hashSetOf( - "bibury animation studios", - ), - producers = hashSetOf( - "dax production", - "nichion", - "pony canyon", - "tbs", - ), - relatedAnime = hashSetOf( - RelatedAnime(URI("https://anidb.net/anime/16165")), - RelatedAnime(URI("https://anilist.co/anime/131520")), - RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), - RelatedAnime(URI("https://animecountdown.com/1577789")), - RelatedAnime(URI("https://anisearch.com/anime/16091")), - RelatedAnime(URI("https://kitsu.app/anime/44229")), - RelatedAnime(URI("https://livechart.me/anime/10488")), - RelatedAnime(URI("https://livechart.me/anime/11921")), - RelatedAnime(URI("https://livechart.me/anime/3448")), - RelatedAnime(URI("https://livechart.me/anime/9428")), - RelatedAnime(URI("https://myanimelist.net/anime/48548")), - RelatedAnime(URI("https://simkl.com/anime/1577789")), - ), - tags = hashSetOf( - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins", - ), - ) - - - val serializedPrettyPrint = """ - { - "sources": [ - "https://anidb.net/anime/18603", - "https://anilist.co/anime/177191", - "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", - "https://anisearch.com/anime/19242", - "https://kitsu.app/anime/48807", - "https://livechart.me/anime/12646", - "https://myanimelist.net/anime/58755", - "https://simkl.com/anime/2448488" - ], - "title": "5-toubun no Hanayome*", - "type": "SPECIAL", - "episodes": 2, - "status": "FINISHED", - "animeSeason": { - "season": "FALL", - "year": 2024 - }, - "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", - "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", - "duration": { - "value": 1440, - "unit": "SECONDS" - }, - "score": { - "arithmeticGeometricMean": 7.616175305477198, - "arithmeticMean": 7.624601113172541, - "median": 7.7272727272727275 - }, - "synonyms": [ - "5-toubun no Hanayome *", - "Go-Toubun no Hanayome *", - "Go-Tōbun no Hanayome *", - "Go-toubun no Hanayome *", - "The Quintessential Quintuplets *", - "The Quintessential Quintuplets Movie*", - "The Quintessential Quintuplets Special 2", - "The Quintessential Quintuplets Specials 2", - "The Quintessential Quintuplets*", - "The Quintessential Quintuplets: Honeymoon Arc", - "五等分の花嫁*", - "五等分の花嫁*", - "五等分的新娘*" - ], - "studios": [ - "bibury animation studios" - ], - "producers": [ - "dax production", - "nichion", - "pony canyon", - "tbs" - ], - "relatedAnime": [ - { - "uri": "https://anidb.net/anime/16165" - }, - { - "uri": "https://anilist.co/anime/131520" - }, - { - "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" - }, - { - "uri": "https://animecountdown.com/1577789" - }, - { - "uri": "https://anisearch.com/anime/16091" - }, - { - "uri": "https://kitsu.app/anime/44229" - }, - { - "uri": "https://livechart.me/anime/10488" - }, - { - "uri": "https://livechart.me/anime/11921" - }, - { - "uri": "https://livechart.me/anime/3448" - }, - { - "uri": "https://livechart.me/anime/9428" - }, - { - "uri": "https://myanimelist.net/anime/48548" - }, - { - "uri": "https://simkl.com/anime/1577789" - } - ], - "tags": [ - "america", - "based on a manga", - "comedy", - "drama", - "ensemble cast", - "female harem", - "harem", - "heterosexual", - "japanese production", - "language barrier", - "male protagonist", - "marriage", - "new", - "predominantly female cast", - "present", - "primarily female cast", - "romance", - "school", - "sequel", - "shounen", - "siblings", - "time", - "twins" - ] - } - """.trimIndent() - - val serializedMinified = """ - {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} - """.trimIndent() - } -} \ No newline at end of file +package io.github.manamiproject.modb.serde + +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.FALL +import io.github.manamiproject.modb.core.anime.AnimeStatus.FINISHED +import io.github.manamiproject.modb.core.anime.AnimeType.SPECIAL +import io.github.manamiproject.modb.core.anime.AnimeType.TV +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.MINUTES +import io.github.manamiproject.modb.core.httpclient.HttpClient +import io.github.manamiproject.modb.core.httpclient.HttpResponse +import io.github.manamiproject.modb.core.httpclient.RequestBody +import io.github.manamiproject.modb.core.httpclient.RetryCase +import io.github.manamiproject.modb.serde.json.deserializer.Deserializer +import io.github.manamiproject.modb.test.shouldNotBeInvoked +import java.io.IOException +import java.io.InputStream +import java.net.URI +import java.net.URL +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION + +internal object TestHttpClient: HttpClient { + override suspend fun get(url: URL, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override suspend fun post(url: URL, requestBody: RequestBody, headers: Map<String, Collection<String>>): HttpResponse = shouldNotBeInvoked() + override fun addRetryCases(vararg retryCases: RetryCase): HttpClient = shouldNotBeInvoked() +} + +internal class TestDeserializer<in IN, out OUT>: Deserializer<IN, OUT> { + override suspend fun deserialize(source: IN): OUT = shouldNotBeInvoked() +} + +internal class TestReadOnceInputStream(private val delegate: InputStream): InputStream() { + private var closed = false + + override fun read(): Int { + if (closed) throw IOException("Stream closed") + return delegate.read() + } + + override fun close() { + closed = true + delegate.close() + } +} + +@Suppress("unused") +internal object TestAnimeRawObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: AnimeRaw + get() = AnimeRaw("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "scores": [], + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","scores":[],"synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Death Note", + _sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + _synonyms = hashSetOf( + "DN", + "デスノート", + ), + _studios = hashSetOf( + "madhouse", + ), + _producers = hashSetOf( + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + _tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "scores": [ + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "d.n. dream partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","scores":[{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1.0,"maxInclusive":10.0}}],"synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["d.n. dream partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "Go-toubun no Hanayome *", + _sources = hashSetOf( + URI("https://anilist.co/anime/177191"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + thumbnail = URI("https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anilist.co/anime/131520")), + ), + _tags = hashSetOf( + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anilist.co/anime/177191" + ], + "title": "Go-toubun no Hanayome *", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "thumbnail": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + } + ], + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anilist.co/anime/131520" + } + ], + "tags": [ + "comedy", + "drama", + "ensemble cast", + "female harem", + "heterosexual", + "language barrier", + "male protagonist", + "marriage", + "primarily female cast", + "romance", + "school", + "shounen", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anilist.co/anime/177191"],"title":"Go-toubun no Hanayome *","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","thumbnail":"https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx177191-ovNZsq8EbIPY.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anilist.co","value":75.0,"range":{"minInclusive":1.0,"maxInclusive":100.0}}],"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anilist.co/anime/131520"}],"tags":["comedy","drama","ensemble cast","female harem","heterosexual","language barrier","male protagonist","marriage","primarily female cast","romance","school","shounen","twins"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: AnimeRaw + get() = AnimeRaw( + _title = "5-toubun no Hanayome*", + _sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + _synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + _studios = hashSetOf( + "bibury animation studios", + ), + _producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + _relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + _tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ).apply { + addScores( + MetaDataProviderScoreValue( + hostname = "anidb.net", + value = 6.63, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "anilist.co", + value = 75.0, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "anime-planet.com", + value = 3.845, + range = 0.5..5.0, + ), + MetaDataProviderScoreValue( + hostname = "anisearch.com", + value = 3.9, + range = 0.1..5.0, + ), + MetaDataProviderScoreValue( + hostname = "kitsu.app", + value = 76.95, + range = 1.0..100.0, + ), + MetaDataProviderScoreValue( + hostname = "livechart.me", + value = 8.35, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "myanimelist.net", + value = 8.62, + range = 1.0..10.0, + ), + MetaDataProviderScoreValue( + hostname = "simkl.com", + value = 7.9, + range = 1.0..10.0, + ), + ) + } + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "scores": [ + { + "hostname": "anidb.net", + "value": 6.63, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "anilist.co", + "value": 75.0, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "anime-planet.com", + "value": 3.845, + "range": { + "minInclusive": 0.5, + "maxInclusive": 5.0 + } + }, + { + "hostname": "anisearch.com", + "value": 3.9, + "range": { + "minInclusive": 0.1, + "maxInclusive": 5.0 + } + }, + { + "hostname": "kitsu.app", + "value": 76.95, + "range": { + "minInclusive": 1.0, + "maxInclusive": 100.0 + } + }, + { + "hostname": "livechart.me", + "value": 8.35, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "myanimelist.net", + "value": 8.62, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + }, + { + "hostname": "simkl.com", + "value": 7.9, + "range": { + "minInclusive": 1.0, + "maxInclusive": 10.0 + } + } + ], + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"scores":[{"hostname":"anidb.net","value":6.63,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"anilist.co","value":75,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"anime-planet.com","value":3.845,"range":{"minInclusive":0.5,"maxInclusive":5.0}},{"hostname":"anisearch.com","value":3.9,"range":{"minInclusive":0.1,"maxInclusive":5}},{"hostname":"kitsu.app","value":76.95,"range":{"minInclusive":1,"maxInclusive":100}},{"hostname":"livechart.me","value":8.35,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"myanimelist.net","value":8.62,"range":{"minInclusive":1,"maxInclusive":10}},{"hostname":"simkl.com","value":7.9,"range":{"minInclusive":1,"maxInclusive":10}}],"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} + +@Suppress("unused") +internal object TestAnimeObjects { + + /** + * Default anime. Only default values are set. + */ + object DefaultAnime { + + val obj: Anime + get() = Anime("default") + + val serializedPrettyPrint = """ + { + "sources": [], + "title": "default", + "type": "UNKNOWN", + "episodes": 0, + "status": "UNKNOWN", + "animeSeason": { + "season": "UNDEFINED", + "year": null + }, + "picture": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png", + "thumbnail": "https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png", + "duration": null, + "score": null, + "synonyms": [], + "studios": [], + "producers": [], + "relatedAnime": [], + "tags": [] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":[],"title":"default","type":"UNKNOWN","episodes":0,"status":"UNKNOWN","animeSeason":{"season":"UNDEFINED"},"picture":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic.png","thumbnail":"https://raw.githubusercontent.com/manami-project/anime-offline-database/master/pics/no_pic_thumbnail.png","synonyms":[],"studios":[],"producers":[],"relatedAnime":[],"tags":[]} + """.trimIndent() + } + + /** + * Default anime of type TV with all optional fields not set to enforce `null` in serialization. + */ + object NullableNotSet { + + val obj: Anime + get() = Anime( + title = "Death Note", + sources = hashSetOf( + URI("https://myanimelist.net/anime/1535"), + ), + type = TV, + episodes = 37, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = UNKNOWN_YEAR, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1079/138100.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1079/138100t.jpg"), + duration = UNKNOWN_DURATION, + score = NoScore, + synonyms = hashSetOf( + "DN", + "デスノート", + ), + studios = hashSetOf( + "madhouse", + ), + producers = hashSetOf( + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/2994")), + ), + tags = hashSetOf( + "psychological", + "shounen", + "supernatural", + "suspense", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/1535" + ], + "title": "Death Note", + "type": "TV", + "episodes": 37, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": null + }, + "picture": "https://cdn.myanimelist.net/images/anime/1079/138100.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1079/138100t.jpg", + "duration": null, + "score": null, + "synonyms": [ + "DN", + "デスノート" + ], + "studios": [ + "madhouse" + ], + "producers": [ + "D.N. Dream Partners", + "nippon television network", + "shueisha", + "vap" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/2994" + } + ], + "tags": [ + "psychological", + "shounen", + "supernatural", + "suspense" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/1535"],"title":"Death Note","type":"TV","episodes":37,"status":"FINISHED","animeSeason":{"season":"FALL"},"picture":"https://cdn.myanimelist.net/images/anime/1079/138100.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1079/138100t.jpg","synonyms":["DN","デスノート"],"studios":["madhouse"],"producers":["D.N. Dream Partners","nippon television network","shueisha","vap"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/2994"}],"tags":["psychological","shounen","supernatural","suspense"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Single metadata provider only. + */ + object AllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://myanimelist.net/anime/58755"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.44, + arithmeticMean = 7.44, + median = 7.44, + ), + synonyms = hashSetOf( + "The Quintessential Quintuplets*", + "五等分の花嫁*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + ), + tags = hashSetOf( + "comedy", + "harem", + "romance", + "school", + "shounen", + ), + ) + + val serializedPrettyPrint = """ + { + "sources": [ + "https://myanimelist.net/anime/58755" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.44, + "arithmeticMean": 7.44, + "median": 7.44 + }, + "synonyms": [ + "The Quintessential Quintuplets*", + "五等分の花嫁*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon" + ], + "relatedAnime": [ + { + "uri": "https://myanimelist.net/anime/48548" + } + ], + "tags": [ + "comedy", + "harem", + "romance", + "school", + "shounen" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://myanimelist.net/anime/58755"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.44,"arithmeticMean":7.44,"median":7.44},"synonyms":["The Quintessential Quintuplets*","五等分の花嫁*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon"],"relatedAnime":[{"uri":"https://myanimelist.net/anime/48548"}],"tags":["comedy","harem","romance","school","shounen"]} + """.trimIndent() + } + + /** + * A special with multiple episodes. Contains a fully merged anime object with all metadata providers. + */ + object FullyMergedAllPropertiesSet { + + val obj: Anime + get() = Anime( + title = "5-toubun no Hanayome*", + sources = hashSetOf( + URI("https://anidb.net/anime/18603"), + URI("https://anilist.co/anime/177191"), + URI("https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2"), + URI("https://anisearch.com/anime/19242"), + URI("https://kitsu.app/anime/48807"), + URI("https://livechart.me/anime/12646"), + URI("https://myanimelist.net/anime/58755"), + URI("https://simkl.com/anime/2448488"), + ), + type = SPECIAL, + episodes = 2, + status = FINISHED, + animeSeason = AnimeSeason( + season = FALL, + year = 2024, + ), + picture = URI("https://cdn.myanimelist.net/images/anime/1915/145336.jpg"), + thumbnail = URI("https://cdn.myanimelist.net/images/anime/1915/145336t.jpg"), + duration = Duration( + value = 24, + unit = MINUTES, + ), + score = ScoreValue( + arithmeticGeometricMean = 7.616175305477198, + arithmeticMean = 7.624601113172541, + median = 7.7272727272727275, + ), + synonyms = hashSetOf( + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*", + ), + studios = hashSetOf( + "bibury animation studios", + ), + producers = hashSetOf( + "dax production", + "nichion", + "pony canyon", + "tbs", + ), + relatedAnime = hashSetOf( + RelatedAnime(URI("https://anidb.net/anime/16165")), + RelatedAnime(URI("https://anilist.co/anime/131520")), + RelatedAnime(URI("https://anime-planet.com/anime/the-quintessential-quintuplets-movie")), + RelatedAnime(URI("https://animecountdown.com/1577789")), + RelatedAnime(URI("https://anisearch.com/anime/16091")), + RelatedAnime(URI("https://kitsu.app/anime/44229")), + RelatedAnime(URI("https://livechart.me/anime/10488")), + RelatedAnime(URI("https://livechart.me/anime/11921")), + RelatedAnime(URI("https://livechart.me/anime/3448")), + RelatedAnime(URI("https://livechart.me/anime/9428")), + RelatedAnime(URI("https://myanimelist.net/anime/48548")), + RelatedAnime(URI("https://simkl.com/anime/1577789")), + ), + tags = hashSetOf( + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins", + ), + ) + + + val serializedPrettyPrint = """ + { + "sources": [ + "https://anidb.net/anime/18603", + "https://anilist.co/anime/177191", + "https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2", + "https://anisearch.com/anime/19242", + "https://kitsu.app/anime/48807", + "https://livechart.me/anime/12646", + "https://myanimelist.net/anime/58755", + "https://simkl.com/anime/2448488" + ], + "title": "5-toubun no Hanayome*", + "type": "SPECIAL", + "episodes": 2, + "status": "FINISHED", + "animeSeason": { + "season": "FALL", + "year": 2024 + }, + "picture": "https://cdn.myanimelist.net/images/anime/1915/145336.jpg", + "thumbnail": "https://cdn.myanimelist.net/images/anime/1915/145336t.jpg", + "duration": { + "value": 1440, + "unit": "SECONDS" + }, + "score": { + "arithmeticGeometricMean": 7.616175305477198, + "arithmeticMean": 7.624601113172541, + "median": 7.7272727272727275 + }, + "synonyms": [ + "5-toubun no Hanayome *", + "Go-Toubun no Hanayome *", + "Go-Tōbun no Hanayome *", + "Go-toubun no Hanayome *", + "The Quintessential Quintuplets *", + "The Quintessential Quintuplets Movie*", + "The Quintessential Quintuplets Special 2", + "The Quintessential Quintuplets Specials 2", + "The Quintessential Quintuplets*", + "The Quintessential Quintuplets: Honeymoon Arc", + "五等分の花嫁*", + "五等分の花嫁*", + "五等分的新娘*" + ], + "studios": [ + "bibury animation studios" + ], + "producers": [ + "dax production", + "nichion", + "pony canyon", + "tbs" + ], + "relatedAnime": [ + { + "uri": "https://anidb.net/anime/16165" + }, + { + "uri": "https://anilist.co/anime/131520" + }, + { + "uri": "https://anime-planet.com/anime/the-quintessential-quintuplets-movie" + }, + { + "uri": "https://animecountdown.com/1577789" + }, + { + "uri": "https://anisearch.com/anime/16091" + }, + { + "uri": "https://kitsu.app/anime/44229" + }, + { + "uri": "https://livechart.me/anime/10488" + }, + { + "uri": "https://livechart.me/anime/11921" + }, + { + "uri": "https://livechart.me/anime/3448" + }, + { + "uri": "https://livechart.me/anime/9428" + }, + { + "uri": "https://myanimelist.net/anime/48548" + }, + { + "uri": "https://simkl.com/anime/1577789" + } + ], + "tags": [ + "america", + "based on a manga", + "comedy", + "drama", + "ensemble cast", + "female harem", + "harem", + "heterosexual", + "japanese production", + "language barrier", + "male protagonist", + "marriage", + "new", + "predominantly female cast", + "present", + "primarily female cast", + "romance", + "school", + "sequel", + "shounen", + "siblings", + "time", + "twins" + ] + } + """.trimIndent() + + val serializedMinified = """ + {"sources":["https://anidb.net/anime/18603","https://anilist.co/anime/177191","https://anime-planet.com/anime/the-quintessential-quintuplets-specials-2","https://anisearch.com/anime/19242","https://kitsu.app/anime/48807","https://livechart.me/anime/12646","https://myanimelist.net/anime/58755","https://simkl.com/anime/2448488"],"title":"5-toubun no Hanayome*","type":"SPECIAL","episodes":2,"status":"FINISHED","animeSeason":{"season":"FALL","year":2024},"picture":"https://cdn.myanimelist.net/images/anime/1915/145336.jpg","thumbnail":"https://cdn.myanimelist.net/images/anime/1915/145336t.jpg","duration":{"value":1440,"unit":"SECONDS"},"score":{"arithmeticGeometricMean":7.616175305477198,"arithmeticMean":7.624601113172541,"median":7.7272727272727275},"synonyms":["5-toubun no Hanayome *","Go-Toubun no Hanayome *","Go-Tōbun no Hanayome *","Go-toubun no Hanayome *","The Quintessential Quintuplets *","The Quintessential Quintuplets Movie*","The Quintessential Quintuplets Special 2","The Quintessential Quintuplets Specials 2","The Quintessential Quintuplets*","The Quintessential Quintuplets: Honeymoon Arc","五等分の花嫁*","五等分の花嫁*","五等分的新娘*"],"studios":["bibury animation studios"],"producers":["dax production","nichion","pony canyon","tbs"],"relatedAnime":[{"uri":"https://anidb.net/anime/16165"},{"uri":"https://anilist.co/anime/131520"},{"uri":"https://anime-planet.com/anime/the-quintessential-quintuplets-movie"},{"uri":"https://animecountdown.com/1577789"},{"uri":"https://anisearch.com/anime/16091"},{"uri":"https://kitsu.app/anime/44229"},{"uri":"https://livechart.me/anime/10488"},{"uri":"https://livechart.me/anime/11921"},{"uri":"https://livechart.me/anime/3448"},{"uri":"https://livechart.me/anime/9428"},{"uri":"https://myanimelist.net/anime/48548"},{"uri":"https://simkl.com/anime/1577789"}],"tags":["america","based on a manga","comedy","drama","ensemble cast","female harem","harem","heterosexual","japanese production","language barrier","male protagonist","marriage","new","predominantly female cast","present","primarily female cast","romance","school","sequel","shounen","siblings","time","twins"]} + """.trimIndent() + } +} \ No newline at end of file diff --git a/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt b/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt index e2df376d..472daea6 100644 --- a/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt +++ b/simkl/src/main/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverter.kt @@ -1,351 +1,351 @@ -package io.github.manamiproject.modb.simkl - -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.converter.AnimeConverter -import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU -import io.github.manamiproject.modb.core.extensions.EMPTY -import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank -import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank -import io.github.manamiproject.modb.core.extensions.remove -import io.github.manamiproject.modb.core.extractor.DataExtractor -import io.github.manamiproject.modb.core.extractor.ExtractionResult -import io.github.manamiproject.modb.core.extractor.extractLabelledLinks -import io.github.manamiproject.modb.core.extractor.XmlDataExtractor -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.extensions.normalize -import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION -import kotlinx.coroutines.withContext -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -/** - * Converts raw data to an [AnimeRaw]. - * @since 1.0.0 - * @property metaDataProviderConfig Configuration for converting data. - * @property extractor Extracts specific data from raw content. - */ -public class SimklAnimeConverter( - private val metaDataProviderConfig: MetaDataProviderConfig = SimklConfig, - private val extractor: DataExtractor = XmlDataExtractor, - private val clock: Clock = Clock.systemDefaultZone(), -): AnimeConverter { - - override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { - val data = extractor.extract( - rawContent, mapOf( - "title" to "//img[@id='detailPosterImg']/@alt", - "episodes" to "//span[@class='episode']/text()", - "episodesFallback" to "//td[@class='SimklTVAboutYearCountry']/text()", - "startDate" to "//span[@itemprop='startDate']/text()", - "source" to "//meta[@property='og:url']/@content", - "airDate" to "//strong[contains(text(),'Air')][contains(text(),'Date')]/../following-sibling::td/text()", - "genres" to "//span[@class='TagGenre']/text()", - "subgenres" to "//a[@class='tag ajList']/text()", - "type" to "//strong[text()='Type:']/..//following-sibling::*/text()", - "duration" to "//meta[@property='og:duration']/@content", - "picture" to "//meta[@property='og:image']/@content", - "relatedAnime" to "//detail-related-item[@id='tvdetailrelations']//div[@class='tvdetailrelationsitems']//a/html()", - "synonyms" to "//td[@itemprop='alternateName']/text()", - "score" to "//div[@itemprop='aggregateRating']//span[@class='SimklTVRatingAverage'][@itemprop='ratingValue']/text()", - "bestRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='bestRating']/text()", - "worstRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='worstRating']/text()", - "studios" to "//strong[text()='Studios:']/..//following-sibling::td//a/text()", - "producers" to "//strong[text()='Producers:']/..//following-sibling::td//a/text()", - ) - ) - - val picture = extractPicture(data) - val title = extractTitle(data) - val episodes = extractEpisodes(data) - - return@withContext AnimeRaw( - _title = title, - episodes = episodes, - type = extractType(data), - picture = picture, - thumbnail = extractThumbnail(picture), - status = extractStatus(data), - duration = extractDuration(episodes, data), - animeSeason = extractAnimeSeason(data), - _sources = extractSourcesEntry(data), - _synonyms = postProcessSynonyms(title, extractSynonyms(data)), - _relatedAnime = extractRelatedAnime(data), - _tags = extractTags(data), - _studios = extractStudios(data), - _producers = extractProducers(data), - ).addScores(extractScore(data)) - } - - private fun extractTitle(data: ExtractionResult) = data.string("title").trim() - - private fun extractEpisodes(data: ExtractionResult): Int { - var numberOfEpisodes = if (!data.notFound("episodes")) { - val totalEpisodes = data.listNotNull<String>("episodes").firstOrNull { it.contains("episodes total") || it.contains("episode total") } ?: EMPTY - totalEpisodes.remove("episodes total") - .remove("episode total") - .trim().toIntOrNull() ?: 0 - } else { - 0 - } - - val fallback = data.stringOrDefault("episodesFallback") - val episodesRegex = "\\d+ episode".toRegex() - if (numberOfEpisodes == 0 && fallback.contains(episodesRegex)) { - numberOfEpisodes = episodesRegex.find(fallback)?.value - ?.remove("episode") - ?.remove("episodes") - ?.trim() - ?.toIntOrNull() - ?: 0 - } - - if (numberOfEpisodes != 0) { - return numberOfEpisodes - } - - val startDate = data.stringOrDefault("startDate") - - if (startDate.eitherNullOrBlank()) { - return 0 - } - - val instant = Instant.parse(startDate) - val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() - - if (localDate.isBefore(LocalDate.now(clock))) { - return 1 - } - - return 0 - } - - private fun extractType(data: ExtractionResult): AnimeType { - val extractedType = data.stringOrDefault("type").normalize().lowercase() - - return when (extractedType) { - "movie" -> MOVIE - "ova" -> OVA - "ona" -> ONA - "tv" -> TV - "special" -> SPECIAL - "music video" -> SPECIAL - else -> UNKNOWN_TYPE - } - } - - private fun extractPicture(data: ExtractionResult): URI { - return when { - data.notFound("picture") || !data.stringOrDefault("picture").contains("posters") -> NO_PICTURE - else -> URI(data.string("picture") - .trim() - .remove("https://og.simkl.in/image/details/?poster=") - .substringBefore('&') - ) - } - } - - private fun extractThumbnail(picture: URI): URI { - return when { - picture == NO_PICTURE -> NO_PICTURE_THUMBNAIL - else -> picture - } - } - - private fun extractStatus(data: ExtractionResult): AnimeStatus { - val prefix = "Air Date: " - val airDate = if (data.notFound("airDate")) { - EMPTY - } else { - data.stringOrDefault("airDate") - .remove(prefix) - .trim() - .lowercase() - } - val startDateString = data.stringOrDefault("startDate") - - val today = LocalDate.now(clock) - var startDate: LocalDate? = null - if (startDateString.neitherNullNorBlank()) { - val instant = Instant.parse(startDateString) - startDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() - } - - return when { - airDate.endsWith("now") -> ONGOING - startDate?.isAfter(today) ?: false -> UPCOMING - airDate.contains("-") -> { - val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy") - val endDate = LocalDate.parse(airDate.substringAfter('-').trim(), formatter) - - when { - endDate.isBefore(today) -> FINISHED - startDate?.isBefore(today) ?: false && endDate.isAfter(today) -> ONGOING - else -> ONGOING - } - } - airDate.contains(YEAR_REGEX) && !airDate.contains("-") -> { - val year = YEAR_REGEX.find(airDate)?.value?.toInt() ?: 0 - - when { - year == 0 -> UNKNOWN_STATUS - year < today.year -> FINISHED - year == today.year && startDate != null && startDate.isBefore(today) -> FINISHED - else -> ONGOING - } - } - startDate?.isBefore(today) ?: false -> FINISHED - else -> UNKNOWN_STATUS - } - } - - private fun extractDuration(episodes: Int, data: ExtractionResult): Duration { - if (data.notFound("duration") || data.int("duration") == 0 || episodes == 0) { - return UNKNOWN_DURATION - } - - val duration = data.int("duration") / episodes - - return Duration( - value = duration, - unit = SECONDS, - ) - } - - private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { - if (data.notFound("startDate") || data.string("startDate") == NO_START_DATE) { - return AnimeSeason( - season = UNDEFINED, - year = UNKNOWN_YEAR, - ) - } - - val instant = Instant.parse(data.string("startDate")) - val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() - val year = if (localDate.year <= YEAR_OF_THE_FIRST_ANIME) { - YEAR_OF_THE_FIRST_ANIME - } else { - localDate.year - } - - val season = when (localDate.monthValue) { - 1, 2, 3 -> WINTER - 4, 5, 6 -> SPRING - 7, 8, 9 -> SUMMER - 10, 11, 12 -> FALL - else -> UNDEFINED - } - - return AnimeSeason( - season = season, - year = year, - ) - } - - private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { - val source = data.string("source") - val animeLinkPrefix = metaDataProviderConfig.buildAnimeLink(EMPTY).toString() - check(source.startsWith(animeLinkPrefix)) { "Page of [$source] is not an anime." } - - val animeId = source.remove(animeLinkPrefix).substringBefore('/') - - return hashSetOf(metaDataProviderConfig.buildAnimeLink(animeId)) - } - - private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { - if (data.notFound("relatedAnime")) { - return hashSetOf() - } - - // <a href="/anime/37429/..." class="tvdetailrelationsitem ..."> ... <div class="tvdetailrelationsitemimagetype">Alternative Setting</div> - return extractor.extractLabelledLinks( - snippets = data.listNotNull<String>("relatedAnime"), - labelSelector = "//div[@class='tvdetailrelationsitemimagetype']/text()", - linkSelector = "//a/@href", - ).flatMap { entry -> entry.links.map { it to entry.label } } - .map { (link, label) -> RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(link.remove("/anime/").substringBefore('/')), label) } - .toHashSet() - } - - private fun extractSynonyms(data: ExtractionResult): HashSet<String> { - return data.stringOrDefault("synonyms").split(',').map { it.trim() }.toHashSet() - } - - private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<Title> { - return synonyms.filterNot { it == title }.toHashSet() - } - - private fun extractTags(data: ExtractionResult): HashSet<Tag> { - val genres = when { - data.notFound("genres") -> emptySet() - else -> data.listNotNull<Tag>("genres") { it.trim() } - } - - val subgenres = when { - data.notFound("subgenres") -> emptySet() - else -> data.listNotNull<Tag>("subgenres") { it.trim() } - } - - return genres.union(subgenres).toHashSet() - } - - private fun extractScore(data: ExtractionResult): MetaDataProviderScore { - if (data.notFound("score")) { - return NoMetaDataProviderScore - } - - val rawScore = data.double("score") - val bestRating = data.double("bestRating") - val worstRating = data.double("worstRating") - - if (rawScore !in worstRating..bestRating) { - return NoMetaDataProviderScore - } - - return MetaDataProviderScoreValue( - hostname = metaDataProviderConfig.hostname(), - value = rawScore, - range = worstRating..bestRating, - ) - } - - private fun extractStudios(data: ExtractionResult): HashSet<Studio> { - return if (data.notFound("studios")) { - hashSetOf() - } else { - data.listNotNull<Studio>("studios").toHashSet() - } - } - - private fun extractProducers(data: ExtractionResult): HashSet<Producer> { - return if (data.notFound("producers")) { - hashSetOf() - } else { - data.listNotNull<Producer>("producers").toHashSet() - } - } - - public companion object { - private const val NO_START_DATE = "1970-01-01T00:00:00Z" - private val YEAR_REGEX = "\\d{4}".toRegex() - - /** - * Singleton of [SimklAnimeConverter] - * @since 1.0.0 - */ - public val instance: SimklAnimeConverter by lazy { SimklAnimeConverter() } - } -} \ No newline at end of file +package io.github.manamiproject.modb.simkl + +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.converter.AnimeConverter +import io.github.manamiproject.modb.core.coroutines.ModbDispatchers.LIMITED_CPU +import io.github.manamiproject.modb.core.extensions.EMPTY +import io.github.manamiproject.modb.core.extensions.eitherNullOrBlank +import io.github.manamiproject.modb.core.extensions.neitherNullNorBlank +import io.github.manamiproject.modb.core.extensions.remove +import io.github.manamiproject.modb.core.extractor.DataExtractor +import io.github.manamiproject.modb.core.extractor.ExtractionResult +import io.github.manamiproject.modb.core.extractor.extractLabelledLinks +import io.github.manamiproject.modb.core.extractor.XmlDataExtractor +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.UNKNOWN as UNKNOWN_STATUS +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.AnimeType.UNKNOWN as UNKNOWN_TYPE +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.extensions.normalize +import io.github.manamiproject.modb.core.anime.Duration.Companion.UNKNOWN as UNKNOWN_DURATION +import kotlinx.coroutines.withContext +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Converts raw data to an [AnimeRaw]. + * @since 1.0.0 + * @property metaDataProviderConfig Configuration for converting data. + * @property extractor Extracts specific data from raw content. + */ +public class SimklAnimeConverter( + private val metaDataProviderConfig: MetaDataProviderConfig = SimklConfig, + private val extractor: DataExtractor = XmlDataExtractor, + private val clock: Clock = Clock.systemDefaultZone(), +): AnimeConverter { + + override suspend fun convert(rawContent: String): AnimeRaw = withContext(LIMITED_CPU) { + val data = extractor.extract( + rawContent, mapOf( + "title" to "//img[@id='detailPosterImg']/@alt", + "episodes" to "//span[@class='episode']/text()", + "episodesFallback" to "//td[@class='SimklTVAboutYearCountry']/text()", + "startDate" to "//span[@itemprop='startDate']/text()", + "source" to "//meta[@property='og:url']/@content", + "airDate" to "//strong[contains(text(),'Air')][contains(text(),'Date')]/../following-sibling::td/text()", + "genres" to "//span[@class='TagGenre']/text()", + "subgenres" to "//a[@class='tag ajList']/text()", + "type" to "//strong[text()='Type:']/..//following-sibling::*/text()", + "duration" to "//meta[@property='og:duration']/@content", + "picture" to "//meta[@property='og:image']/@content", + "relatedAnime" to "//detail-related-item[@id='tvdetailrelations']//div[@class='tvdetailrelationsitems']//a/html()", + "synonyms" to "//td[@itemprop='alternateName']/text()", + "score" to "//div[@itemprop='aggregateRating']//span[@class='SimklTVRatingAverage'][@itemprop='ratingValue']/text()", + "bestRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='bestRating']/text()", + "worstRating" to "//div[@itemprop='aggregateRating']//span[@itemprop='worstRating']/text()", + "studios" to "//strong[text()='Studios:']/..//following-sibling::td//a/text()", + "producers" to "//strong[text()='Producers:']/..//following-sibling::td//a/text()", + ) + ) + + val picture = extractPicture(data) + val title = extractTitle(data) + val episodes = extractEpisodes(data) + + return@withContext AnimeRaw( + _title = title, + episodes = episodes, + type = extractType(data), + picture = picture, + thumbnail = extractThumbnail(picture), + status = extractStatus(data), + duration = extractDuration(episodes, data), + animeSeason = extractAnimeSeason(data), + _sources = extractSourcesEntry(data), + _synonyms = postProcessSynonyms(title, extractSynonyms(data)), + _relatedAnime = extractRelatedAnime(data), + _tags = extractTags(data), + _studios = extractStudios(data), + _producers = extractProducers(data), + ).addScores(extractScore(data)) + } + + private fun extractTitle(data: ExtractionResult) = data.string("title").trim() + + private fun extractEpisodes(data: ExtractionResult): Int { + var numberOfEpisodes = if (!data.notFound("episodes")) { + val totalEpisodes = data.listNotNull<String>("episodes").firstOrNull { it.contains("episodes total") || it.contains("episode total") } ?: EMPTY + totalEpisodes.remove("episodes total") + .remove("episode total") + .trim().toIntOrNull() ?: 0 + } else { + 0 + } + + val fallback = data.stringOrDefault("episodesFallback") + val episodesRegex = "\\d+ episode".toRegex() + if (numberOfEpisodes == 0 && fallback.contains(episodesRegex)) { + numberOfEpisodes = episodesRegex.find(fallback)?.value + ?.remove("episode") + ?.remove("episodes") + ?.trim() + ?.toIntOrNull() + ?: 0 + } + + if (numberOfEpisodes != 0) { + return numberOfEpisodes + } + + val startDate = data.stringOrDefault("startDate") + + if (startDate.eitherNullOrBlank()) { + return 0 + } + + val instant = Instant.parse(startDate) + val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() + + if (localDate.isBefore(LocalDate.now(clock))) { + return 1 + } + + return 0 + } + + private fun extractType(data: ExtractionResult): AnimeType { + val extractedType = data.stringOrDefault("type").normalize().lowercase() + + return when (extractedType) { + "movie" -> MOVIE + "ova" -> OVA + "ona" -> ONA + "tv" -> TV + "special" -> SPECIAL + "music video" -> SPECIAL + else -> UNKNOWN_TYPE + } + } + + private fun extractPicture(data: ExtractionResult): URI { + return when { + data.notFound("picture") || !data.stringOrDefault("picture").contains("posters") -> NO_PICTURE + else -> URI(data.string("picture") + .trim() + .remove("https://og.simkl.in/image/details/?poster=") + .substringBefore('&') + ) + } + } + + private fun extractThumbnail(picture: URI): URI { + return when { + picture == NO_PICTURE -> NO_PICTURE_THUMBNAIL + else -> picture + } + } + + private fun extractStatus(data: ExtractionResult): AnimeStatus { + val prefix = "Air Date: " + val airDate = if (data.notFound("airDate")) { + EMPTY + } else { + data.stringOrDefault("airDate") + .remove(prefix) + .trim() + .lowercase() + } + val startDateString = data.stringOrDefault("startDate") + + val today = LocalDate.now(clock) + var startDate: LocalDate? = null + if (startDateString.neitherNullNorBlank()) { + val instant = Instant.parse(startDateString) + startDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() + } + + return when { + airDate.endsWith("now") -> ONGOING + startDate?.isAfter(today) ?: false -> UPCOMING + airDate.contains("-") -> { + val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy") + val endDate = LocalDate.parse(airDate.substringAfter('-').trim(), formatter) + + when { + endDate.isBefore(today) -> FINISHED + startDate?.isBefore(today) ?: false && endDate.isAfter(today) -> ONGOING + else -> ONGOING + } + } + airDate.contains(YEAR_REGEX) && !airDate.contains("-") -> { + val year = YEAR_REGEX.find(airDate)?.value?.toInt() ?: 0 + + when { + year == 0 -> UNKNOWN_STATUS + year < today.year -> FINISHED + year == today.year && startDate != null && startDate.isBefore(today) -> FINISHED + else -> ONGOING + } + } + startDate?.isBefore(today) ?: false -> FINISHED + else -> UNKNOWN_STATUS + } + } + + private fun extractDuration(episodes: Int, data: ExtractionResult): Duration { + if (data.notFound("duration") || data.int("duration") == 0 || episodes == 0) { + return UNKNOWN_DURATION + } + + val duration = data.int("duration") / episodes + + return Duration( + value = duration, + unit = SECONDS, + ) + } + + private fun extractAnimeSeason(data: ExtractionResult): AnimeSeason { + if (data.notFound("startDate") || data.string("startDate") == NO_START_DATE) { + return AnimeSeason( + season = UNDEFINED, + year = UNKNOWN_YEAR, + ) + } + + val instant = Instant.parse(data.string("startDate")) + val localDate = instant.atZone(ZoneId.of("UTC")).toLocalDate() + val year = if (localDate.year <= YEAR_OF_THE_FIRST_ANIME) { + YEAR_OF_THE_FIRST_ANIME + } else { + localDate.year + } + + val season = when (localDate.monthValue) { + 1, 2, 3 -> WINTER + 4, 5, 6 -> SPRING + 7, 8, 9 -> SUMMER + 10, 11, 12 -> FALL + else -> UNDEFINED + } + + return AnimeSeason( + season = season, + year = year, + ) + } + + private fun extractSourcesEntry(data: ExtractionResult): HashSet<URI> { + val source = data.string("source") + val animeLinkPrefix = metaDataProviderConfig.buildAnimeLink(EMPTY).toString() + check(source.startsWith(animeLinkPrefix)) { "Page of [$source] is not an anime." } + + val animeId = source.remove(animeLinkPrefix).substringBefore('/') + + return hashSetOf(metaDataProviderConfig.buildAnimeLink(animeId)) + } + + private suspend fun extractRelatedAnime(data: ExtractionResult): HashSet<RelatedAnime> { + if (data.notFound("relatedAnime")) { + return hashSetOf() + } + + // <a href="/anime/37429/..." class="tvdetailrelationsitem ..."> ... <div class="tvdetailrelationsitemimagetype">Alternative Setting</div> + return extractor.extractLabelledLinks( + snippets = data.listNotNull<String>("relatedAnime"), + labelSelector = "//div[@class='tvdetailrelationsitemimagetype']/text()", + linkSelector = "//a/@href", + ).flatMap { entry -> entry.links.map { it to entry.label } } + .map { (link, label) -> RelatedAnime.of(metaDataProviderConfig.buildAnimeLink(link.remove("/anime/").substringBefore('/')), label) } + .toHashSet() + } + + private fun extractSynonyms(data: ExtractionResult): HashSet<String> { + return data.stringOrDefault("synonyms").split(',').map { it.trim() }.toHashSet() + } + + private fun postProcessSynonyms(title: String, synonyms: HashSet<String>): HashSet<Title> { + return synonyms.filterNot { it == title }.toHashSet() + } + + private fun extractTags(data: ExtractionResult): HashSet<Tag> { + val genres = when { + data.notFound("genres") -> emptySet() + else -> data.listNotNull<Tag>("genres") { it.trim() } + } + + val subgenres = when { + data.notFound("subgenres") -> emptySet() + else -> data.listNotNull<Tag>("subgenres") { it.trim() } + } + + return genres.union(subgenres).toHashSet() + } + + private fun extractScore(data: ExtractionResult): MetaDataProviderScore { + if (data.notFound("score")) { + return NoMetaDataProviderScore + } + + val rawScore = data.double("score") + val bestRating = data.double("bestRating") + val worstRating = data.double("worstRating") + + if (rawScore !in worstRating..bestRating) { + return NoMetaDataProviderScore + } + + return MetaDataProviderScoreValue( + hostname = metaDataProviderConfig.hostname(), + value = rawScore, + range = worstRating..bestRating, + ) + } + + private fun extractStudios(data: ExtractionResult): HashSet<Studio> { + return if (data.notFound("studios")) { + hashSetOf() + } else { + data.listNotNull<Studio>("studios").toHashSet() + } + } + + private fun extractProducers(data: ExtractionResult): HashSet<Producer> { + return if (data.notFound("producers")) { + hashSetOf() + } else { + data.listNotNull<Producer>("producers").toHashSet() + } + } + + public companion object { + private const val NO_START_DATE = "1970-01-01T00:00:00Z" + private val YEAR_REGEX = "\\d{4}".toRegex() + + /** + * Singleton of [SimklAnimeConverter] + * @since 1.0.0 + */ + public val instance: SimklAnimeConverter by lazy { SimklAnimeConverter() } + } +} \ No newline at end of file diff --git a/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt b/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt index 2fcf17f0..30b7949d 100644 --- a/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt +++ b/simkl/src/test/kotlin/io/github/manamiproject/modb/simkl/SimklAnimeConverterTest.kt @@ -1,1177 +1,1177 @@ -package io.github.manamiproject.modb.simkl - -import io.github.manamiproject.modb.core.config.AnimeId -import io.github.manamiproject.modb.core.config.FileSuffix -import io.github.manamiproject.modb.core.config.MetaDataProviderConfig -import io.github.manamiproject.modb.core.anime.* -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE -import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL -import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR -import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* -import io.github.manamiproject.modb.core.anime.AnimeStatus.* -import io.github.manamiproject.modb.core.anime.AnimeType.* -import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* -import io.github.manamiproject.modb.core.config.Hostname -import io.github.manamiproject.modb.test.exceptionExpected -import io.github.manamiproject.modb.test.loadTestResource -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.net.URI -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset.UTC -import kotlin.test.Test - -internal class SimklAnimeConverterTest { - - @Nested - inner class TitleTests { - - @Test - fun `correctly extracts primary title`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/primary_title.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Fullmetal Alchemist: Brotherhood") - } - } - - @Test - fun `extract title from alt tag, because SimklLoginBgTitle may return other text than the title like 'Blue Box'`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/37171.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("Umi no Triton") - } - } - - @Test - fun `correctly extracts title including special chars`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/special_chars.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.title).isEqualTo("The Quintessential Quintuplets~") - } - } - } - - @Nested - inner class EpisodesTests { - - @Test - fun `unknown number of episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/unknown.html") - - val converter = SimklAnimeConverter( - metaDataProviderConfig = testSimklConfig, - clock = Clock.fixed(Instant.parse("2025-01-01T16:02:42.00Z"), UTC) - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(0) - } - } - - @Test - fun `1 episode`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isOne() - } - } - - @Test - fun `10 episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/10.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(10) - } - } - - @Test - fun `100 episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/100.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(100) - } - } - - @Test - fun `1818 episodes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1818.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1818) - } - } - - @Test - fun `2 episodes retrieved from fallback`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/fallback.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(2) - } - } - - @Test - fun `correctly extracts 130 on an ongoing anime whose last episode was 162 and next is 163`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/130_episodes_while_ongoing.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(164) - } - } - - @Test - fun `correctly extracts episodes for a series that ended and is missing the info in the header`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/series_ended_missing_info.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.episodes).isEqualTo(1) - } - } - } - - @Nested - inner class TypeTests { - - @Test - fun `type is Movie`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/movie.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(MOVIE) - } - } - - @Test - fun `type is OVA`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ova.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(OVA) - } - } - - @Test - fun `type is TV`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/tv.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(TV) - } - } - - @Test - fun `type is ONA`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ona.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(ONA) - } - } - - @Test - fun `type is SPECIAL`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/special.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - - @Test - fun `'Music Video' is mapped to SPECIAL`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/music_video.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.type).isEqualTo(SPECIAL) - } - } - } - - @Nested - inner class PictureAndThumbnailTests { - - @Test - fun `neither picture nor thumbnail available`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(NO_PICTURE) - assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) - } - } - - @Test - fun `picture and thumbnail`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.picture).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) - assertThat(result.thumbnail).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) - } - } - } - - @Nested - inner class StatusTests { - - @Test - fun `correctly extracts ONGOING anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/ongoing.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(ONGOING) - } - } - - @Test - fun `correctly extracts UPCOMING anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/upcoming.html") - - val converter = SimklAnimeConverter( - metaDataProviderConfig = testSimklConfig, - clock = Clock.fixed(Instant.parse("2020-05-01T16:02:42.00Z"), UTC), - ) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(UPCOMING) - } - } - - @Test - fun `correctly extracts FINISHED anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/finished.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - - @Test - fun `airdate in the past is mapped to FINISHED`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/airdate-year-only-indicating-finished.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.status).isEqualTo(FINISHED) - } - } - } - - @Nested - inner class AnimeSeasonTests { - - @Nested - inner class SeasonTests { - - @ParameterizedTest - @ValueSource(ints = [1, 2, 3]) - fun `season is 'WINTER'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(WINTER) - } - } - - @ParameterizedTest - @ValueSource(ints = [4, 5, 6]) - fun `season is 'SPRING'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SPRING) - } - } - - @ParameterizedTest - @ValueSource(ints = [7, 8, 9]) - fun `season is 'SUMMER'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(SUMMER) - } - } - - @ParameterizedTest - @ValueSource(ints = [10, 11, 12]) - fun `season is 'FALL'`(value: Int) { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(FALL) - } - } - } - - @Nested - inner class YearOfPremiereTests { - - @Test - fun `correctly extract the year of premiere if start and end year are the same`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/same_year.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2023) - } - } - - @Test - fun `correctly extract the year of premiere`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/overlapping_years.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(2009) - } - } - - @Test - fun `correctly adjusts the year of premiere of it is set in year before the first official anime release`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/release-prior-first-ever-anime.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.year).isEqualTo(YEAR_OF_THE_FIRST_ANIME) - } - } - } - - @Test - fun `both unknown`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/unknown.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) - assertThat(result.animeSeason.year).isEqualTo(UNKNOWN_YEAR) - } - } - } - - @Nested - inner class SourcesTests { - - @Test - fun `correctly extracts id 41586`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/41586.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.sources).containsExactlyInAnyOrder( - URI("https://simkl.com/anime/41586"), - ) - } - } - - @Test - fun `refuses a page which is no longer in the anime section`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/38399_movie.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = exceptionExpected<IllegalStateException> { - converter.convert(testFile) - } - - // then - assertThat(result).hasMessage("Page of [https://simkl.com/movies/38399/drums-of-fu-manchu] is not an anime.") - } - } - } - - @Nested - inner class TagsTests { - - @Test - fun `correctly extracts both genres and subgenres`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/tags/genres_and_subgenres.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.tags).containsExactlyInAnyOrder( - "colour coded", - "comedy", - "harem", - "high school", - "japanese production", - "male protagonist", - "predominantly female cast", - "romance", - "school", - "school life", - "shounen", - ) - } - } - } - - @Nested - inner class RelatedAnimeTests { - - @Test - fun `correctly extracts both directly and indirectly related anime`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/relatedAnime/directly_and_indirectly_related.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.relatedAnime).containsExactlyInAnyOrder( - RelatedAnime(URI("https://simkl.com/anime/37429"), "Alternative Setting"), - RelatedAnime(URI("https://simkl.com/anime/38317"), "Side story"), - RelatedAnime(URI("https://simkl.com/anime/39885")), - ) - } - } - } - - @Nested - inner class SynonymsTests { - - @Test - fun `correctly extracts multiple synoynms`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/multiple_synonyms.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).containsExactlyInAnyOrder( - "鋼の錬金術師 FULLMETAL ALCHEMIST", - "Fullmetal Alchemist: Brotherhood", - "鋼の錬金術師", - ) - } - } - - @Test - fun `no synonyms`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/no_synonyms.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.synonyms).isEmpty() - } - } - } - - @Nested - inner class DurationTests { - - @Test - fun `correctly extracts minutes`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/minutes.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration( - value = 26, - MINUTES, - )) - } - } - - @Test - fun `correctly extracts hours`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/hours.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.duration).isEqualTo(Duration( - value = 7800, - SECONDS, - )) - } - } - } - - @Nested - inner class ScoresTests { - - @Test - fun `successfully extracts score`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val converter = SimklAnimeConverter(testSimklConfig) - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).hasSize(1) - assertThat(result.scores.first().scaledValue()).isEqualTo(8.7) - } - } - - @Test - fun `returns NoMetaDataProviderScore`() { - runTest { - // given - val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val converter = SimklAnimeConverter(testAnidbConfig) - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/no-score.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - - @Test - fun `returns NoMetaDataProviderScore if the score is outside of the rating range`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val converter = SimklAnimeConverter(testSimklConfig) - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/out_of_range.html") - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.scores).isEmpty() - } - } - } - - @Nested - inner class StudiosTests { - - @Test - fun `multiple studios`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/multiple_studios.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).containsExactlyInAnyOrder( - "satelight", - "hornets", - ) - } - } - - @Test - fun `no studios`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/no_studios.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.studios).isEmpty() - } - } - } - - @Nested - inner class ProducersTests { - - @Test - fun `multiple producers`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/multiple_producers.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).containsExactlyInAnyOrder( - "movic", - "crunchyroll", - "north stars pictures", - "jy animation", - "ca-cygames anime fund", - "mixer", - "magic capsule", - ) - } - } - - @Test - fun `no producers`() { - runTest { - // given - val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { - override fun hostname(): Hostname = SimklConfig.hostname() - override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) - override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() - } - - val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/no_producers.html") - - val converter = SimklAnimeConverter(testSimklConfig) - - // when - val result = converter.convert(testFile) - - // then - assertThat(result.producers).isEmpty() - } - } - } - - @Nested - inner class CompanionObjectTests { - - @Test - fun `instance property always returns same instance`() { - // given - val previous = SimklAnimeConverter.instance - - // when - val result = SimklAnimeConverter.instance - - // then - assertThat(result).isExactlyInstanceOf(SimklAnimeConverter::class.java) - assertThat(result === previous).isTrue() - } - } -} \ No newline at end of file +package io.github.manamiproject.modb.simkl + +import io.github.manamiproject.modb.core.config.AnimeId +import io.github.manamiproject.modb.core.config.FileSuffix +import io.github.manamiproject.modb.core.config.MetaDataProviderConfig +import io.github.manamiproject.modb.core.anime.* +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE +import io.github.manamiproject.modb.core.anime.AnimeMedia.NO_PICTURE_THUMBNAIL +import io.github.manamiproject.modb.core.anime.AnimeSeason.Companion.UNKNOWN_YEAR +import io.github.manamiproject.modb.core.anime.AnimeSeason.Season.* +import io.github.manamiproject.modb.core.anime.AnimeStatus.* +import io.github.manamiproject.modb.core.anime.AnimeType.* +import io.github.manamiproject.modb.core.anime.Duration.TimeUnit.* +import io.github.manamiproject.modb.core.config.Hostname +import io.github.manamiproject.modb.test.exceptionExpected +import io.github.manamiproject.modb.test.loadTestResource +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.URI +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset.UTC +import kotlin.test.Test + +internal class SimklAnimeConverterTest { + + @Nested + inner class TitleTests { + + @Test + fun `correctly extracts primary title`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/primary_title.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Fullmetal Alchemist: Brotherhood") + } + } + + @Test + fun `extract title from alt tag, because SimklLoginBgTitle may return other text than the title like 'Blue Box'`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/37171.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("Umi no Triton") + } + } + + @Test + fun `correctly extracts title including special chars`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/title/special_chars.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.title).isEqualTo("The Quintessential Quintuplets~") + } + } + } + + @Nested + inner class EpisodesTests { + + @Test + fun `unknown number of episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/unknown.html") + + val converter = SimklAnimeConverter( + metaDataProviderConfig = testSimklConfig, + clock = Clock.fixed(Instant.parse("2025-01-01T16:02:42.00Z"), UTC) + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(0) + } + } + + @Test + fun `1 episode`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isOne() + } + } + + @Test + fun `10 episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/10.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(10) + } + } + + @Test + fun `100 episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/100.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(100) + } + } + + @Test + fun `1818 episodes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/1818.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1818) + } + } + + @Test + fun `2 episodes retrieved from fallback`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/fallback.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(2) + } + } + + @Test + fun `correctly extracts 130 on an ongoing anime whose last episode was 162 and next is 163`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/130_episodes_while_ongoing.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(164) + } + } + + @Test + fun `correctly extracts episodes for a series that ended and is missing the info in the header`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/episodes/series_ended_missing_info.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.episodes).isEqualTo(1) + } + } + } + + @Nested + inner class TypeTests { + + @Test + fun `type is Movie`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/movie.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(MOVIE) + } + } + + @Test + fun `type is OVA`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ova.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(OVA) + } + } + + @Test + fun `type is TV`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/tv.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(TV) + } + } + + @Test + fun `type is ONA`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/ona.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(ONA) + } + } + + @Test + fun `type is SPECIAL`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/special.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + + @Test + fun `'Music Video' is mapped to SPECIAL`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/type/music_video.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.type).isEqualTo(SPECIAL) + } + } + } + + @Nested + inner class PictureAndThumbnailTests { + + @Test + fun `neither picture nor thumbnail available`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/neither_picture_nor_thumbnail.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(NO_PICTURE) + assertThat(result.thumbnail).isEqualTo(NO_PICTURE_THUMBNAIL) + } + } + + @Test + fun `picture and thumbnail`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/picture_and_thumbnail/picture_and_thumbnail_available.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.picture).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) + assertThat(result.thumbnail).isEqualTo(URI("https://simkl.in/posters/11/113489741c880d3962_m.webp")) + } + } + } + + @Nested + inner class StatusTests { + + @Test + fun `correctly extracts ONGOING anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/ongoing.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(ONGOING) + } + } + + @Test + fun `correctly extracts UPCOMING anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/upcoming.html") + + val converter = SimklAnimeConverter( + metaDataProviderConfig = testSimklConfig, + clock = Clock.fixed(Instant.parse("2020-05-01T16:02:42.00Z"), UTC), + ) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(UPCOMING) + } + } + + @Test + fun `correctly extracts FINISHED anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/finished.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + + @Test + fun `airdate in the past is mapped to FINISHED`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/status/airdate-year-only-indicating-finished.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.status).isEqualTo(FINISHED) + } + } + } + + @Nested + inner class AnimeSeasonTests { + + @Nested + inner class SeasonTests { + + @ParameterizedTest + @ValueSource(ints = [1, 2, 3]) + fun `season is 'WINTER'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(WINTER) + } + } + + @ParameterizedTest + @ValueSource(ints = [4, 5, 6]) + fun `season is 'SPRING'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SPRING) + } + } + + @ParameterizedTest + @ValueSource(ints = [7, 8, 9]) + fun `season is 'SUMMER'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(SUMMER) + } + } + + @ParameterizedTest + @ValueSource(ints = [10, 11, 12]) + fun `season is 'FALL'`(value: Int) { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/season/$value.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(FALL) + } + } + } + + @Nested + inner class YearOfPremiereTests { + + @Test + fun `correctly extract the year of premiere if start and end year are the same`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/same_year.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2023) + } + } + + @Test + fun `correctly extract the year of premiere`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/overlapping_years.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(2009) + } + } + + @Test + fun `correctly adjusts the year of premiere of it is set in year before the first official anime release`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/yearOfPremiere/release-prior-first-ever-anime.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.year).isEqualTo(YEAR_OF_THE_FIRST_ANIME) + } + } + } + + @Test + fun `both unknown`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/animeSeason/unknown.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.animeSeason.season).isEqualTo(UNDEFINED) + assertThat(result.animeSeason.year).isEqualTo(UNKNOWN_YEAR) + } + } + } + + @Nested + inner class SourcesTests { + + @Test + fun `correctly extracts id 41586`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/41586.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.sources).containsExactlyInAnyOrder( + URI("https://simkl.com/anime/41586"), + ) + } + } + + @Test + fun `refuses a page which is no longer in the anime section`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/sources/38399_movie.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = exceptionExpected<IllegalStateException> { + converter.convert(testFile) + } + + // then + assertThat(result).hasMessage("Page of [https://simkl.com/movies/38399/drums-of-fu-manchu] is not an anime.") + } + } + } + + @Nested + inner class TagsTests { + + @Test + fun `correctly extracts both genres and subgenres`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/tags/genres_and_subgenres.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.tags).containsExactlyInAnyOrder( + "colour coded", + "comedy", + "harem", + "high school", + "japanese production", + "male protagonist", + "predominantly female cast", + "romance", + "school", + "school life", + "shounen", + ) + } + } + } + + @Nested + inner class RelatedAnimeTests { + + @Test + fun `correctly extracts both directly and indirectly related anime`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/relatedAnime/directly_and_indirectly_related.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://simkl.com/anime/37429"), "Alternative Setting"), + RelatedAnime(URI("https://simkl.com/anime/38317"), "Side story"), + RelatedAnime(URI("https://simkl.com/anime/39885")), + ) + } + } + } + + @Nested + inner class SynonymsTests { + + @Test + fun `correctly extracts multiple synoynms`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/multiple_synonyms.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).containsExactlyInAnyOrder( + "鋼の錬金術師 FULLMETAL ALCHEMIST", + "Fullmetal Alchemist: Brotherhood", + "鋼の錬金術師", + ) + } + } + + @Test + fun `no synonyms`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/synonyms/no_synonyms.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.synonyms).isEmpty() + } + } + } + + @Nested + inner class DurationTests { + + @Test + fun `correctly extracts minutes`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/minutes.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration( + value = 26, + MINUTES, + )) + } + } + + @Test + fun `correctly extracts hours`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/duration/hours.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.duration).isEqualTo(Duration( + value = 7800, + SECONDS, + )) + } + } + } + + @Nested + inner class ScoresTests { + + @Test + fun `successfully extracts score`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val converter = SimklAnimeConverter(testSimklConfig) + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).hasSize(1) + assertThat(result.scores.first().scaledValue()).isEqualTo(8.7) + } + } + + @Test + fun `returns NoMetaDataProviderScore`() { + runTest { + // given + val testAnidbConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val converter = SimklAnimeConverter(testAnidbConfig) + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/no-score.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + + @Test + fun `returns NoMetaDataProviderScore if the score is outside of the rating range`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val converter = SimklAnimeConverter(testSimklConfig) + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/scores/out_of_range.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.scores).isEmpty() + } + } + } + + @Nested + inner class StudiosTests { + + @Test + fun `multiple studios`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/multiple_studios.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).containsExactlyInAnyOrder( + "satelight", + "hornets", + ) + } + } + + @Test + fun `no studios`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/studios/no_studios.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.studios).isEmpty() + } + } + } + + @Nested + inner class ProducersTests { + + @Test + fun `multiple producers`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/multiple_producers.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).containsExactlyInAnyOrder( + "movic", + "crunchyroll", + "north stars pictures", + "jy animation", + "ca-cygames anime fund", + "mixer", + "magic capsule", + ) + } + } + + @Test + fun `no producers`() { + runTest { + // given + val testSimklConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = SimklConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = SimklConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = SimklConfig.fileSuffix() + } + + val testFile = loadTestResource<String>("SimklAnimeConverterTest/producers/no_producers.html") + + val converter = SimklAnimeConverter(testSimklConfig) + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.producers).isEmpty() + } + } + } + + @Nested + inner class CompanionObjectTests { + + @Test + fun `instance property always returns same instance`() { + // given + val previous = SimklAnimeConverter.instance + + // when + val result = SimklAnimeConverter.instance + + // then + assertThat(result).isExactlyInstanceOf(SimklAnimeConverter::class.java) + assertThat(result === previous).isTrue() + } + } +} \ No newline at end of file From be6752b9474722f53f18569ef22fb65d4eead1c8 Mon Sep 17 00:00:00 2001 From: Christian Gambale <gambale.christian@gmail.com> Date: Wed, 28 Oct 2026 03:17:25 +0100 Subject: [PATCH 3/4] Fix label extraction logic for related anime Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt b/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt index ce388863..8b6ef3ca 100644 --- a/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt +++ b/animenewsnetwork/src/main/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverter.kt @@ -288,8 +288,8 @@ public class AnimenewsnetworkAnimeConverter( return data.listNotNull<String>("relatedAnime").flatMap { html -> relatedAnimeLinkRegex.findAll(html).map { match -> val textBefore = html.substring(0, match.range.first).substringAfterLast('>') - val label = if (textBefore.contains('[')) { - textBefore.substringAfterLast('[') + val label = if (textBefore.contains('[') || textBefore.trimStart().startsWith(',')) { + textBefore.substringAfterLast('[').trimStart().removePrefix(",") } else { relationInBracketsRegex.find(match.groups["after"]?.value ?: EMPTY)?.groups?.get("label")?.value } From 8b2c63f860870b88bc3a7c0187538d8ea68d979f Mon Sep 17 00:00:00 2001 From: Christian Gambale <gambale.christian@gmail.com> Date: Wed, 28 Oct 2026 03:21:19 +0100 Subject: [PATCH 4/4] ANN: test that comma-separated relations keep their own label --- .../AnimenewsnetworkAnimeConverterTest.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt b/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt index 705b43f6..5cd39033 100644 --- a/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt +++ b/animenewsnetwork/src/test/kotlin/io/github/manamiproject/AnimenewsnetworkAnimeConverterTest.kt @@ -1010,6 +1010,32 @@ internal class AnimenewsnetworkAnimeConverterTest { } } + @Test + fun `relations in brackets separated by comma each keep their own label`() { + runTest { + // given + val testAnimenewsnetworkConfig = object : MetaDataProviderConfig by TestMetaDataProviderConfig { + override fun hostname(): Hostname = AnimenewsnetworkConfig.hostname() + override fun buildAnimeLink(id: AnimeId): URI = AnimenewsnetworkConfig.buildAnimeLink(id) + override fun fileSuffix(): FileSuffix = AnimenewsnetworkConfig.fileSuffix() + } + + val converter = AnimenewsnetworkAnimeConverter(testAnimenewsnetworkConfig) + + // [ part of <a>[Gundam]</a>, compilation of <a>Mobile Suit Gundam Wing (TV)</a> ] + val testFile = loadTestResource<String>("AnimenewsnetworkAnimeConverterTest/duration/one-hour-per-episode.html") + + // when + val result = converter.convert(testFile) + + // then + assertThat(result.relatedAnime).containsExactlyInAnyOrder( + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=4189"), "part of"), + RelatedAnime(URI("https://animenewsnetwork.com/encyclopedia/anime.php?id=26"), "compilation of"), + ) + } + } + @Test fun `no related anime, no-relation in-brackets, but an adaption`() { runTest {