New MangaSearchQuery implementation proposal
parent
8c966c3e23
commit
c0ea9cadd7
@ -0,0 +1,130 @@
|
||||
package org.koitharu.kotatsu.parsers.model.search
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
/**
|
||||
* Represents a search query for filtering and sorting manga search results.
|
||||
* This class is immutable and must be constructed using the [Builder].
|
||||
*
|
||||
* @property criteria The set of search criteria applied to the query.
|
||||
* @property order The sorting order for the results (optional).
|
||||
* @property offset The offset number for paginated search results (optional).
|
||||
*/
|
||||
public class MangaSearchQuery private constructor(
|
||||
@JvmField public val criteria: Set<QueryCriteria<*>> = emptySet(),
|
||||
@JvmField public val order: SortOrder? = null,
|
||||
@JvmField public val offset: Int? = null,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MangaSearchQuery) return false
|
||||
|
||||
return criteria == other.criteria &&
|
||||
order == other.order &&
|
||||
offset == other.offset
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = criteria.hashCode()
|
||||
result = 31 * result + (order?.hashCode() ?: 0)
|
||||
result = 31 * result + (offset ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
public companion object {
|
||||
public fun builder(): Builder = Builder()
|
||||
}
|
||||
|
||||
public class Builder {
|
||||
private var criteria: MutableSet<QueryCriteria<*>> = mutableSetOf()
|
||||
private var order: SortOrder? = null
|
||||
private var offset: Int? = null
|
||||
|
||||
public fun copy(searchQuery: MangaSearchQuery): Builder = apply {
|
||||
this.criteria = searchQuery.criteria as MutableSet<QueryCriteria<*>>
|
||||
this.order = searchQuery.order
|
||||
this.offset = searchQuery.offset
|
||||
}
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
public fun criterion(criterion: QueryCriteria<*>): Builder = apply {
|
||||
validateCriterion(criterion)
|
||||
this.criteria.add(criterion)
|
||||
}
|
||||
|
||||
public fun order(order: SortOrder?): Builder = apply { this.order = order }
|
||||
|
||||
public fun offset(offset: Int?): Builder = apply { this.offset = offset }
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
public fun build(): MangaSearchQuery {
|
||||
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the provided [QueryCriteria] to ensure type correctness.
|
||||
*
|
||||
* @param criterion The search criterion to validate.
|
||||
* @throws IllegalArgumentException If the criterion type does not match the expected type.
|
||||
*/
|
||||
private fun validateCriterion(criterion: QueryCriteria<*>) {
|
||||
try {
|
||||
val expectedType = criterion.field.type
|
||||
val actualType: Class<*>? = when (criterion) {
|
||||
is QueryCriteria.Include<*> -> criterion.values.first().javaClass
|
||||
is QueryCriteria.Exclude<*> -> criterion.values.first().javaClass
|
||||
is QueryCriteria.Match<*> -> criterion.value.javaClass
|
||||
is QueryCriteria.Range<*> -> {
|
||||
if (criterion.from.javaClass != criterion.to.javaClass) {
|
||||
throw IllegalArgumentException(
|
||||
"Mismatched types for field '${criterion.field}'. 'from' and 'to' should have same types"
|
||||
)
|
||||
}
|
||||
criterion.from.javaClass
|
||||
}
|
||||
}
|
||||
|
||||
val isCompatibleIntType = (expectedType == Int::class.java && actualType == Integer::class.java) ||
|
||||
(expectedType == Integer::class.java && actualType == Int::class.java)
|
||||
|
||||
if (actualType != null && !expectedType.isAssignableFrom(actualType) && !isCompatibleIntType) {
|
||||
throw IllegalArgumentException(
|
||||
"Invalid type for ${criterion.field}. Expected: ${expectedType.simpleName}, but got: ${actualType.simpleName}"
|
||||
)
|
||||
}
|
||||
} catch (e: NoSuchElementException) {
|
||||
throw IllegalArgumentException(
|
||||
"QueryCriteria values should not be empty"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deduplicateCriteria(criteria: Set<QueryCriteria<*>>): Set<QueryCriteria<*>> {
|
||||
val uniqueCriteria = mutableMapOf<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>()
|
||||
|
||||
for (criterion in criteria) {
|
||||
val key = criterion.field to criterion::class.java
|
||||
val existing = uniqueCriteria[key]
|
||||
|
||||
when {
|
||||
existing == null -> uniqueCriteria[key] = criterion
|
||||
|
||||
existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> {
|
||||
uniqueCriteria[key] = QueryCriteria.Include(criterion.field,existing.values union criterion.values)
|
||||
}
|
||||
|
||||
existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> {
|
||||
uniqueCriteria[key] = QueryCriteria.Exclude(criterion.field,existing.values union criterion.values)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException(
|
||||
"Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueCriteria.values.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package org.koitharu.kotatsu.parsers.model.search
|
||||
|
||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||
|
||||
public data class MangaSearchQueryCapabilities(
|
||||
val capabilities: Set<SearchCapability> = emptySet(),
|
||||
) {
|
||||
public companion object {
|
||||
@InternalParsersApi
|
||||
public fun from(filterCapabilities: MangaListFilterCapabilities): MangaSearchQueryCapabilities {
|
||||
return MangaSearchQueryCapabilities(
|
||||
capabilities = setOfNotNull(
|
||||
filterCapabilities.isMultipleTagsSupported.takeIf { it }?.let {
|
||||
SearchCapability(
|
||||
field = TAG, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||
)
|
||||
},
|
||||
filterCapabilities.isTagsExclusionSupported.takeIf { it }?.let {
|
||||
SearchCapability(
|
||||
field = TAG, criteriaTypes = setOf(Exclude::class), multiValue = true, otherCriteria = true
|
||||
)
|
||||
},
|
||||
filterCapabilities.isSearchSupported.takeIf { it }?.let {
|
||||
SearchCapability(
|
||||
field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = false
|
||||
)
|
||||
},
|
||||
filterCapabilities.isSearchWithFiltersSupported.takeIf { it }?.let {
|
||||
SearchCapability(
|
||||
field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true
|
||||
)
|
||||
},
|
||||
filterCapabilities.isYearSupported.takeIf { it }?.let {
|
||||
SearchCapability(
|
||||
field = PUBLICATION_YEAR, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true
|
||||
)
|
||||
},
|
||||
filterCapabilities.isYearRangeSupported.takeIf { it }?.let {
|
||||
SearchCapability(
|
||||
field = PUBLICATION_YEAR, criteriaTypes = setOf(Range::class), multiValue = false, otherCriteria = true
|
||||
)
|
||||
},
|
||||
filterCapabilities.isOriginalLocaleSupported.takeIf { it }?.let {
|
||||
SearchCapability(
|
||||
field = ORIGINAL_LANGUAGE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||
)
|
||||
},
|
||||
SearchCapability(
|
||||
field = LANGUAGE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||
),
|
||||
SearchCapability(
|
||||
field = STATE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||
),
|
||||
SearchCapability(
|
||||
field = CONTENT_TYPE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||
),
|
||||
SearchCapability(
|
||||
field = CONTENT_RATING, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||
),
|
||||
SearchCapability(
|
||||
field = DEMOGRAPHIC, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@InternalParsersApi
|
||||
public fun validate(query: MangaSearchQuery) {
|
||||
val strictFields = capabilities.filter { !it.otherCriteria }.map { it.field }.toSet()
|
||||
val usedStrictFields = query.criteria.map { it.field }.toSet().intersect(strictFields)
|
||||
|
||||
if (usedStrictFields.isNotEmpty() && query.criteria.size > 1) {
|
||||
throw IllegalArgumentException(
|
||||
"Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria."
|
||||
)
|
||||
}
|
||||
|
||||
for (criterion in query.criteria) {
|
||||
val capability = capabilities.find { it.field == criterion.field }
|
||||
?: throw IllegalArgumentException("Unsupported search field: ${criterion.field}")
|
||||
|
||||
if (criterion::class !in capability.criteriaTypes) {
|
||||
throw IllegalArgumentException(
|
||||
"Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}"
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure single value per criterion if supportMultiValue is false
|
||||
if (!capability.multiValue) {
|
||||
when (criterion) {
|
||||
is Include<*> -> if (criterion.values.size > 1)
|
||||
throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
|
||||
is Exclude<*> -> if (criterion.values.size > 1)
|
||||
throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
|
||||
is Range<*> -> {} // Range is always valid (from, to)
|
||||
is Match<*> -> {} // Match always has a single value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package org.koitharu.kotatsu.parsers.model.search
|
||||
|
||||
/**
|
||||
* Represents a generic search criterion used for filtering manga search results.
|
||||
* Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T].
|
||||
*
|
||||
* @param T The type of value associated with the search criterion.
|
||||
* @property field The field to which this search criterion applies.
|
||||
*/
|
||||
public sealed class QueryCriteria<T>(
|
||||
@JvmField public val field: SearchableField
|
||||
) {
|
||||
public class Include<T : Any>(field: SearchableField, @JvmField public val values: Set<T>) : QueryCriteria<T>(field) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Include<*>) return false
|
||||
return field == other.field && values == other.values
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 31 * field.hashCode() + values.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
public class Exclude<T : Any>(field: SearchableField, @JvmField public val values: Set<T>) : QueryCriteria<T>(field) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Exclude<*>) return false
|
||||
return field == other.field && values == other.values
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 31 * field.hashCode() + values.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
public class Range<T : Comparable<T>>(field: SearchableField, @JvmField public val from: T, @JvmField public val to: T) : QueryCriteria<T>(field) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Range<*>) return false
|
||||
return field == other.field && from == other.from && to == other.to
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = field.hashCode()
|
||||
result = 31 * result + from.hashCode()
|
||||
result = 31 * result + to.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
public class Match<T : Any>(field: SearchableField, @JvmField public val value: T) : QueryCriteria<T>(field) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Match<*>) return false
|
||||
return field == other.field && value == other.value
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 31 * field.hashCode() + value.hashCode()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.parsers.model.search
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
public data class SearchCapability (
|
||||
@JvmField public val field: SearchableField,
|
||||
@JvmField public val criteriaTypes: Set<KClass<out QueryCriteria<*>>>,
|
||||
@JvmField public val multiValue: Boolean,
|
||||
@JvmField public val otherCriteria: Boolean,
|
||||
)
|
||||
@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.parsers.model.search
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Represents the various fields that can be used for searching manga.
|
||||
* Each field is associated with a specific data type that defines its expected values.
|
||||
*
|
||||
* @property type The Java class representing the expected type of values for this field.
|
||||
*/
|
||||
public enum class SearchableField(public val type: Class<*>) {
|
||||
TITLE_NAME(String::class.java),
|
||||
TAG(MangaTag::class.java),
|
||||
AUTHOR(MangaTag::class.java),
|
||||
LANGUAGE(Locale::class.java),
|
||||
ORIGINAL_LANGUAGE(Locale::class.java),
|
||||
STATE(MangaState::class.java),
|
||||
CONTENT_TYPE(ContentType::class.java),
|
||||
CONTENT_RATING(ContentRating::class.java),
|
||||
DEMOGRAPHIC(Demographic::class.java),
|
||||
PUBLICATION_YEAR(Int::class.java);
|
||||
}
|
||||
@ -0,0 +1,154 @@
|
||||
package org.koitharu.kotatsu.parsers.util
|
||||
|
||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria
|
||||
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||
|
||||
/**
|
||||
* Converts a [MangaListFilter] into a [MangaSearchQuery].
|
||||
*
|
||||
* This function iterates through the filter attributes in [MangaListFilter] and creates corresponding
|
||||
* search criteria in a [MangaSearchQuery.Builder].
|
||||
*
|
||||
* @param filter The [MangaListFilter] to convert.
|
||||
* @return A [MangaSearchQuery] constructed based on the given [filter].
|
||||
*/
|
||||
@InternalParsersApi
|
||||
public fun convertToMangaSearchQuery(offset: Int, sortOrder: SortOrder, filter: MangaListFilter): MangaSearchQuery {
|
||||
return MangaSearchQuery.Builder().apply {
|
||||
offset(offset)
|
||||
order(sortOrder)
|
||||
if (filter.tags.isNotEmpty()) criterion(QueryCriteria.Include(TAG, filter.tags))
|
||||
if (filter.tagsExclude.isNotEmpty()) criterion(QueryCriteria.Exclude(TAG, filter.tagsExclude))
|
||||
if (filter.states.isNotEmpty()) criterion(QueryCriteria.Include(STATE, filter.states))
|
||||
if (filter.types.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_TYPE, filter.types))
|
||||
if (filter.contentRating.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_RATING, filter.contentRating))
|
||||
if (filter.demographics.isNotEmpty()) criterion(QueryCriteria.Include(DEMOGRAPHIC, filter.demographics))
|
||||
if (validateYear(filter.yearFrom) || validateYear(filter.yearTo)) {
|
||||
criterion(QueryCriteria.Range(PUBLICATION_YEAR, filter.yearFrom, filter.yearTo))
|
||||
}
|
||||
if (validateYear(filter.year)) {
|
||||
criterion(QueryCriteria.Match(PUBLICATION_YEAR, filter.year))
|
||||
}
|
||||
filter.locale?.takeIf { it != null }?.let {
|
||||
criterion(QueryCriteria.Include(LANGUAGE, setOf(it)))
|
||||
}
|
||||
filter.originalLocale?.takeIf { it != null }?.let {
|
||||
criterion(QueryCriteria.Include(ORIGINAL_LANGUAGE, setOf(it)))
|
||||
}
|
||||
filter.query?.takeIf { it.isNotBlank() }?.let {
|
||||
criterion(QueryCriteria.Match(TITLE_NAME, it))
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a {@link MangaSearchQuery} into a {@link MangaListFilter}.
|
||||
* <p>
|
||||
* This method iterates through the search criteria defined in the provided {@code searchQuery}
|
||||
* and applies them to a {@link MangaListFilter.Builder}. The criteria are processed based on
|
||||
* their types, such as inclusion, exclusion, equality checks, range filtering, and pattern matching.
|
||||
* </p>
|
||||
* <p>
|
||||
* Supported criteria:
|
||||
* <ul>
|
||||
* <li>{@link QueryCriteria.Include} - Adds tags, states, content types, content ratings, demographics, and languages.</li>
|
||||
* <li>{@link QueryCriteria.Exclude} - Excludes tags.</li>
|
||||
* <li>{@link QueryCriteria.Equals} - Sets specific values like publication year.</li>
|
||||
* <li>{@link QueryCriteria.Between} - Sets a range of values like publication year range.</li>
|
||||
* <li>{@link QueryCriteria.Match} - Adds a search pattern for the title name.</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
* <p>
|
||||
* If an unsupported field is encountered, an {@link UnsupportedOperationException} is thrown.
|
||||
* </p>
|
||||
*
|
||||
* @param searchQuery The {@link MangaSearchQuery} to convert.
|
||||
* @return A {@link MangaListFilter} constructed based on the given {@code searchQuery}.
|
||||
* @throws UnsupportedOperationException If the search criteria contain unsupported fields.
|
||||
*/
|
||||
@InternalParsersApi
|
||||
public fun convertToMangaListFilter(searchQuery: MangaSearchQuery): MangaListFilter {
|
||||
return MangaListFilter.Builder().apply {
|
||||
for (criterion in searchQuery.criteria) {
|
||||
when (criterion) {
|
||||
is QueryCriteria.Include<*> -> handleInclude(this, criterion)
|
||||
is QueryCriteria.Exclude<*> -> handleExclude(this, criterion)
|
||||
is QueryCriteria.Range<*> -> handleBetween(this, criterion)
|
||||
is QueryCriteria.Match<*> -> handleMatch(this, criterion)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun handleInclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.Include<*>) {
|
||||
val type = criterion.field.type
|
||||
|
||||
when (criterion.field) {
|
||||
TAG -> builder.addTags(filterValues(criterion, type))
|
||||
STATE -> builder.addStates(filterValues(criterion, type))
|
||||
CONTENT_TYPE -> builder.addTypes(filterValues(criterion, type))
|
||||
CONTENT_RATING -> builder.addContentRatings(filterValues(criterion, type))
|
||||
DEMOGRAPHIC -> builder.addDemographics(filterValues(criterion, type))
|
||||
LANGUAGE -> builder.locale(getFirstValue(criterion, type))
|
||||
ORIGINAL_LANGUAGE -> builder.originalLocale(getFirstValue(criterion, type))
|
||||
else -> throw UnsupportedOperationException("Unsupported field for Include criterion: ${criterion.field}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.Exclude<*>) {
|
||||
val type = criterion.field.type
|
||||
|
||||
when (criterion.field) {
|
||||
TAG -> builder.excludeTags(filterValues(criterion, type))
|
||||
else -> throw UnsupportedOperationException("Unsupported field for Exclude criterion: ${criterion.field}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBetween(builder: MangaListFilter.Builder, criterion: QueryCriteria.Range<*>) {
|
||||
val type = criterion.field.type
|
||||
|
||||
when (criterion.field) {
|
||||
PUBLICATION_YEAR -> {
|
||||
builder.yearFrom(getValue(criterion.from, type, YEAR_UNKNOWN))
|
||||
builder.yearTo(getValue(criterion.to, type, YEAR_UNKNOWN))
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported field for Between criterion: ${criterion.field}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMatch(builder: MangaListFilter.Builder, criterion: QueryCriteria.Match<*>) {
|
||||
val type = criterion.field.type
|
||||
|
||||
when (criterion.field) {
|
||||
TITLE_NAME -> builder.query(getValue(criterion.value, type, ""))
|
||||
PUBLICATION_YEAR -> builder.year(getValue(criterion.value, type, YEAR_UNKNOWN))
|
||||
else -> throw UnsupportedOperationException("Unsupported field for Match criterion: ${criterion.field}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> filterValues(criterion: QueryCriteria.Include<*>, type: Class<*>): List<T> {
|
||||
return criterion.values.filter { type.isInstance(it) } as List<T>
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> filterValues(criterion: QueryCriteria.Exclude<*>, type: Class<*>): List<T> {
|
||||
return criterion.values.filter { type.isInstance(it) } as List<T>
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> getFirstValue(criterion: QueryCriteria.Include<*>, type: Class<*>): T? {
|
||||
return criterion.values.firstOrNull { type.isInstance(it) } as? T
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> getValue(value: Any?, type: Class<*>, default: T): T {
|
||||
val isCompatibleIntType = (type == Int::class.java && Integer::class.isInstance(value))
|
||||
|
||||
return if (type.isInstance(value) || isCompatibleIntType) value as T else default
|
||||
}
|
||||
|
||||
private fun validateYear(year: Int) = year != null && year != YEAR_UNKNOWN
|
||||
@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.parsers.model.search
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||
import java.util.Locale
|
||||
|
||||
class MangaSearchQueryCapabilitiesTest {
|
||||
|
||||
private val capabilities = MangaSearchQueryCapabilities(
|
||||
capabilities = setOf(
|
||||
SearchCapability(TITLE_NAME, setOf(Match::class), multiValue = false, otherCriteria = false),
|
||||
SearchCapability(TAG, setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true),
|
||||
SearchCapability(PUBLICATION_YEAR, setOf(Range::class), multiValue = false, otherCriteria = true),
|
||||
SearchCapability(STATE, setOf(Include::class), multiValue = false, otherCriteria = true),
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun validateValidSingleCriterionQuery() {
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Match(TITLE_NAME, "title"))
|
||||
.build()
|
||||
|
||||
assertDoesNotThrow { capabilities.validate(query) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateUnsupportedFieldThrowsException() {
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH)))
|
||||
.build()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateUnsupportedMultiValueThrowsException() {
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED)))
|
||||
.build()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateMultipleCriteriaWithOtherCriteriaAllowed() {
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2"))))
|
||||
.criterion(Exclude(TAG, setOf(buildTag("tag3"))))
|
||||
.build()
|
||||
|
||||
assertDoesNotThrow { capabilities.validate(query) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateMultipleCriteriaWithStrictCapabilityThrowsException() {
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Match(TITLE_NAME, "title"))
|
||||
.criterion(Range(PUBLICATION_YEAR, 1990, 2000))
|
||||
.build()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
|
||||
}
|
||||
|
||||
private fun buildTag(name: String) = MangaTag(title = name, key = "${name}Key", source = MangaParserSource.DUMMY)
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package org.koitharu.kotatsu.parsers.util
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||
import java.util.*
|
||||
|
||||
class ListFilterToSearchQueryConverterTest {
|
||||
|
||||
@Test
|
||||
fun convertToMangaSearchQueryTest() {
|
||||
val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
|
||||
val excludedTags = setOf(buildMangaTag("exclude_tag"))
|
||||
val states = setOf(MangaState.ONGOING)
|
||||
val contentRatings = setOf(ContentRating.SAFE)
|
||||
val contentTypes = setOf(MANGA, MANHUA)
|
||||
val demographics = setOf(SEINEN)
|
||||
|
||||
val filter = MangaListFilter(
|
||||
query = "title_name",
|
||||
tags = tags,
|
||||
tagsExclude = excludedTags,
|
||||
locale = Locale.ENGLISH,
|
||||
originalLocale = Locale.JAPANESE,
|
||||
states = states,
|
||||
contentRating = contentRatings,
|
||||
types = contentTypes,
|
||||
demographics = demographics,
|
||||
year = 2020,
|
||||
yearFrom = 1997,
|
||||
yearTo = 2024
|
||||
)
|
||||
|
||||
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
|
||||
|
||||
val expectedQuery = MangaSearchQuery.builder()
|
||||
.offset(0)
|
||||
.order(SortOrder.NEWEST)
|
||||
.criterion(Match(TITLE_NAME, "title_name"))
|
||||
.criterion(Include(TAG, tags))
|
||||
.criterion(Exclude(TAG, excludedTags))
|
||||
.criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
|
||||
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
|
||||
.criterion(Include(STATE, states))
|
||||
.criterion(Include(CONTENT_RATING, contentRatings))
|
||||
.criterion(Include(CONTENT_TYPE, contentTypes))
|
||||
.criterion(Include(DEMOGRAPHIC, demographics))
|
||||
.criterion(Range(PUBLICATION_YEAR, 1997, 2024))
|
||||
.criterion(Match(PUBLICATION_YEAR, 2020))
|
||||
.build()
|
||||
|
||||
assertEquals(expectedQuery, searchQuery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convertToMangaSearchQueryWithEmptyFieldsTest() {
|
||||
val filter = MangaListFilter()
|
||||
|
||||
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
|
||||
|
||||
assertEquals(MangaSearchQuery.builder().offset(0).order(SortOrder.NEWEST).build(), searchQuery)
|
||||
}
|
||||
|
||||
private fun buildMangaTag(name: String): MangaTag {
|
||||
return MangaTag(
|
||||
key = "${name}Key",
|
||||
title = name,
|
||||
source = MangaParserSource.DUMMY
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package org.koitharu.kotatsu.parsers.util
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType.*
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
|
||||
import org.koitharu.kotatsu.parsers.model.search.*
|
||||
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||
import java.util.*
|
||||
|
||||
class ConvertToMangaListFilterTest {
|
||||
|
||||
@Test
|
||||
fun convertToMangaListFilterTest() {
|
||||
val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
|
||||
val excludedTags = setOf(buildMangaTag("exclude_tag"))
|
||||
val states = setOf(MangaState.ONGOING)
|
||||
val contentRatings = setOf(ContentRating.SAFE)
|
||||
val contentTypes = setOf(MANGA, MANHUA)
|
||||
val demographics = setOf(SEINEN)
|
||||
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Match(TITLE_NAME, "title_name"))
|
||||
.criterion(Include(TAG, tags))
|
||||
.criterion(Exclude(TAG, excludedTags))
|
||||
.criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
|
||||
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
|
||||
.criterion(Include(STATE, states))
|
||||
.criterion(Include(CONTENT_RATING, contentRatings))
|
||||
.criterion(Include(CONTENT_TYPE, contentTypes))
|
||||
.criterion(Include(DEMOGRAPHIC, demographics))
|
||||
.criterion(Range(PUBLICATION_YEAR, 1997, 2024))
|
||||
.criterion(Match(PUBLICATION_YEAR, 2020))
|
||||
.build()
|
||||
|
||||
val listFilter = convertToMangaListFilter(query)
|
||||
|
||||
assertEquals(listFilter.query, "title_name")
|
||||
assertEquals(listFilter.tags, tags)
|
||||
assertEquals(listFilter.tagsExclude, excludedTags)
|
||||
assertEquals(listFilter.locale, Locale.ENGLISH)
|
||||
assertEquals(listFilter.originalLocale, Locale.JAPANESE)
|
||||
assertEquals(listFilter.states, states)
|
||||
assertEquals(listFilter.contentRating, contentRatings)
|
||||
assertEquals(listFilter.types, contentTypes)
|
||||
assertEquals(listFilter.demographics, demographics)
|
||||
assertEquals(listFilter.year, 2020)
|
||||
assertEquals(listFilter.yearFrom, 1997)
|
||||
assertEquals(listFilter.yearTo, 2024)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convertToMangaListFilterWithMultipleTagsIncludeTest() {
|
||||
val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
|
||||
val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4"))
|
||||
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Include(TAG, tags1))
|
||||
.criterion(Include(TAG, tags2))
|
||||
.build()
|
||||
|
||||
val listFilter = convertToMangaListFilter(query)
|
||||
|
||||
assertEquals(listFilter.tags, tags1 union tags2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convertToMangaListFilterWithUnsupportedFieldTest() {
|
||||
val query = MangaSearchQuery.builder()
|
||||
.criterion(Include(AUTHOR, setOf(buildMangaTag("author"))))
|
||||
.build()
|
||||
|
||||
val exception = assertThrows<UnsupportedOperationException> {
|
||||
convertToMangaListFilter(query)
|
||||
}
|
||||
|
||||
assert(exception.message!!.contains("Unsupported field for Include criterion: AUTHOR"))
|
||||
}
|
||||
|
||||
private fun buildMangaTag(name: String): MangaTag {
|
||||
return MangaTag(
|
||||
key = "${name}Key",
|
||||
title = name,
|
||||
source = MangaParserSource.DUMMY,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue