Group search results by month (#80)
Relevance scoring never fit a calendar: a list of events is easier to read as a calendar than as a ranking. Results are now grouped into months under sticky headers — this month, then the months ahead, then the months behind counting backwards — with what is still to come listed before what has passed inside each month, so a month spanning today needs only one header. That replaces the field/position scoring and the title-coverage tie-break; matching itself is unchanged (every token, any field, case folded past ASCII, mid-word allowed), as are the highlight spans, the description excerpt and the past-event fade. To keep the ordering from bringing back what started this, events the query only reached through their description are held out of the calendar and listed under their own heading at the end. Months come from spanFirstDay, so an all-day event is filed by its own day rather than slipping a month back west of UTC (#82).
This commit is contained in:
@@ -27,7 +27,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearchRanker
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
@@ -68,7 +68,7 @@ interface CalendarDataSource {
|
||||
* whitespace-separated token has to appear in the title, description or
|
||||
* location. Reads the Events table directly so the search is unbounded in
|
||||
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||
* superset — [EventSearchRanker] makes the final call and orders the hits.
|
||||
* superset — [EventSearch] makes the final call and orders the hits.
|
||||
* [query] is assumed non-blank.
|
||||
*/
|
||||
fun searchEvents(query: String): List<SearchCandidate>
|
||||
@@ -591,17 +591,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||
ensureObserversRegistered()
|
||||
val tokens = EventSearchRanker.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
// Every token has to appear somewhere, so the groups are ANDed. This is
|
||||
// only a pre-filter — EventSearchRanker re-checks each token with proper
|
||||
// only a pre-filter — EventSearch re-checks each token with proper
|
||||
// case folding, and re-checks the tokens dropped by the cap above.
|
||||
val match = tokens.joinToString(" AND ") {
|
||||
"(${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\')"
|
||||
}
|
||||
val args = tokens.flatMap { token -> List(3) { likePattern(token) } }.toTypedArray()
|
||||
val args = tokens.flatMap { token: String -> List(3) { likePattern(token) } }.toTypedArray()
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
@@ -640,7 +640,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
* One token as a `LIKE` pattern. SQLite folds case for ASCII only, so a
|
||||
* non-ASCII cased letter is substituted with the single-character wildcard
|
||||
* `_` — "ärzte" queries as `%_rzte%` and so still reaches "Ärzte". That
|
||||
* matches more than it should on purpose; [EventSearchRanker] does the real
|
||||
* matches more than it should on purpose; [EventSearch] does the real
|
||||
* comparison. Uncased scripts stay literal, keeping the filter selective.
|
||||
*/
|
||||
private fun likePattern(token: String): String {
|
||||
|
||||
@@ -21,7 +21,7 @@ interface CalendarRepository {
|
||||
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||
* [query] is blank. Searches the whole history/future (see
|
||||
* [CalendarDataSource.searchEvents]) and leaves ranking to
|
||||
* [de.jeanlucmakiola.calendula.domain.EventSearchRanker].
|
||||
* [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** An event with the free text the search may have matched it on. */
|
||||
@@ -30,36 +34,49 @@ data class SearchHit(
|
||||
val isPast: Boolean = false,
|
||||
)
|
||||
|
||||
/** One month's worth of hits, as the results list draws them under a header. */
|
||||
data class SearchMonth(
|
||||
val year: Int,
|
||||
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||
val monthNumber: Int,
|
||||
val hits: List<SearchHit>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Turning a typed query into ranked hits.
|
||||
* A finished search: a calendar of hits, plus the ones that only turned up
|
||||
* inside a description and would otherwise clutter it.
|
||||
*/
|
||||
data class SearchResults(
|
||||
val months: List<SearchMonth>,
|
||||
val inDescriptions: List<SearchHit>,
|
||||
) {
|
||||
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||
|
||||
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a typed query into the results list.
|
||||
*
|
||||
* Matching is token-wise: the query splits on whitespace and an event has to
|
||||
* satisfy *every* token, each in any of title / location / description. Case is
|
||||
* folded through Kotlin's [String.indexOf] rather than SQL's `LIKE`, which folds
|
||||
* ASCII only — "ärzte" has to find "Ärzte".
|
||||
*
|
||||
* Ranking answers the complaint behind #80's follow-up: a description hit used
|
||||
* to outrank a title hit purely by being sooner. A token scores by the field it
|
||||
* matched ([FIELD_TITLE] > [FIELD_LOCATION] > [FIELD_DESCRIPTION]) and by how it
|
||||
* sat in that field (whole-field start > word start > mid-word), and an event
|
||||
* ranks by the *weakest* field the query needed — one token findable only in the
|
||||
* description drags the whole event below the title matches, however soon it is.
|
||||
* Equally-scoring events are then split by time: everything from today onwards
|
||||
* comes before what is already behind us, so a match tomorrow outranks an
|
||||
* equally good one from yesterday. The boundary is the *day*, not the moment —
|
||||
* an event earlier today is still today's business, and ties inside a day fall
|
||||
* to how much of the title the match covers, so an exact title beats one that
|
||||
* merely begins the same way.
|
||||
* Results are a calendar rather than a ranking: hits are grouped by the month
|
||||
* they fall in, the current month first, then the months ahead, then the months
|
||||
* behind counting backwards. Inside a month what is still to come reads
|
||||
* forwards and what has passed follows it, so an event tomorrow sits above one
|
||||
* from yesterday. The day is the boundary, not the moment — an event that ended
|
||||
* at 19:00 is still today's business and keeps its place in today's order.
|
||||
*
|
||||
* An event the query only reached through its description is held back in
|
||||
* [SearchResults.inDescriptions]: those are the matches that made search
|
||||
* confusing when a stray word in a holiday's notes landed among events actually
|
||||
* named for the query.
|
||||
*/
|
||||
object EventSearchRanker {
|
||||
|
||||
private const val FIELD_TITLE = 3
|
||||
private const val FIELD_LOCATION = 2
|
||||
private const val FIELD_DESCRIPTION = 1
|
||||
|
||||
private const val KIND_MID_WORD = 1
|
||||
private const val KIND_WORD_START = 2
|
||||
private const val KIND_FIELD_START = 3
|
||||
object EventSearch {
|
||||
|
||||
/** Characters of description kept around the match in a row's snippet. */
|
||||
private const val SNIPPET_LENGTH = 96
|
||||
@@ -77,44 +94,84 @@ object EventSearchRanker {
|
||||
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Rank [candidates] against [query], dropping any that don't carry every
|
||||
* token. [now] marks an individual event as over; [todayStart] is where the
|
||||
* ordering splits current from past, so everything still on today's page
|
||||
* ranks with the upcoming events.
|
||||
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||
* [now] marks an individual event as over; [todayStart] is where an event
|
||||
* stops being current, so everything still on today's page keeps its place
|
||||
* among the upcoming ones. [zone] resolves each event to a calendar month —
|
||||
* via [spanFirstDay], so an all-day event doesn't slip into the month before
|
||||
* it west of UTC (#82).
|
||||
*/
|
||||
fun rank(
|
||||
fun search(
|
||||
candidates: List<SearchCandidate>,
|
||||
query: String,
|
||||
now: Instant,
|
||||
todayStart: Instant,
|
||||
): List<SearchHit> {
|
||||
zone: TimeZone,
|
||||
): SearchResults {
|
||||
val tokens = tokenize(query)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val scored = candidates.mapNotNull { candidate -> score(candidate, tokens, now, todayStart) }
|
||||
return scored
|
||||
.sortedWith(
|
||||
compareByDescending<Scored> { it.weakest }
|
||||
.thenByDescending { it.total }
|
||||
.thenBy { if (it.current) 0 else 1 }
|
||||
.thenByDescending { it.titleCoverage }
|
||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now) }
|
||||
val order = compareBy<Matched> { if (it.event.end >= todayStart) 0 else 1 }
|
||||
.thenComparator { a, b ->
|
||||
val aStart = a.hit.event.start
|
||||
val bStart = b.hit.event.start
|
||||
// Same side of today by the key above: what is left reads
|
||||
// forwards, what is behind us backwards from today.
|
||||
if (a.current) aStart.compareTo(bStart) else bStart.compareTo(aStart)
|
||||
},
|
||||
)
|
||||
.map { it.hit }
|
||||
// What is left reads forwards; what is behind us reads backwards
|
||||
// from today, nearest first.
|
||||
if (a.event.end >= todayStart) {
|
||||
a.event.start.compareTo(b.event.start)
|
||||
} else {
|
||||
b.event.start.compareTo(a.event.start)
|
||||
}
|
||||
}
|
||||
|
||||
private fun score(
|
||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||
return SearchResults(
|
||||
months = groupByMonth(dated, zone, todayStart),
|
||||
inDescriptions = inDescriptions.map { it.hit },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The dated hits as months: the one we are in, then forwards, then backwards.
|
||||
* Each month keeps the order it was handed, so a month holding both sides of
|
||||
* today lists what is left before what has passed.
|
||||
*/
|
||||
private fun groupByMonth(
|
||||
dated: List<Matched>,
|
||||
zone: TimeZone,
|
||||
todayStart: Instant,
|
||||
): List<SearchMonth> {
|
||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||
return dated
|
||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||
when {
|
||||
it.key == currentKey -> 0
|
||||
it.key > currentKey -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenComparator { a, b ->
|
||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||
},
|
||||
)
|
||||
.map { (key, entries) ->
|
||||
SearchMonth(
|
||||
year = key / 12,
|
||||
monthNumber = key % 12 + 1,
|
||||
hits = entries.map { it.hit },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||
|
||||
private fun match(
|
||||
candidate: SearchCandidate,
|
||||
tokens: List<String>,
|
||||
now: Instant,
|
||||
todayStart: Instant,
|
||||
): Scored? {
|
||||
): Matched? {
|
||||
val title = candidate.event.title
|
||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||
// Collapsed once so a multi-line description matches and snippets the
|
||||
@@ -124,80 +181,34 @@ object EventSearchRanker {
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
var weakest = Int.MAX_VALUE
|
||||
var total = 0
|
||||
for (token in tokens) {
|
||||
val best = bestScore(token, title, location, description) ?: return null
|
||||
weakest = minOf(weakest, best)
|
||||
total += best
|
||||
// Mid-word counts as a match — German compounds mean "termin" has to keep
|
||||
// finding "Zahnarzttermin".
|
||||
val present = { token: String ->
|
||||
title.contains(token, ignoreCase = true) ||
|
||||
location?.contains(token, ignoreCase = true) == true ||
|
||||
description?.contains(token, ignoreCase = true) == true
|
||||
}
|
||||
if (!tokens.all(present)) return null
|
||||
|
||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val titleSpans = spansIn(title, tokens)
|
||||
return Scored(
|
||||
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||
return Matched(
|
||||
hit = SearchHit(
|
||||
event = candidate.event,
|
||||
titleSpans = titleSpans,
|
||||
locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(),
|
||||
locationSpans = locationSpans,
|
||||
descriptionSnippet = description
|
||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||
?.let { snippet(it, descriptionSpans) },
|
||||
isPast = candidate.event.end < now,
|
||||
),
|
||||
weakest = weakest,
|
||||
total = total,
|
||||
titleCoverage = coverage(title, titleSpans),
|
||||
current = candidate.event.end >= todayStart,
|
||||
// Nothing in the name or the place the query could have caught on —
|
||||
// the only reason this event is here is buried in its notes.
|
||||
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of [text] the match accounts for — 1.0 when the query *is* the
|
||||
* title. What separates an exact hit from one that merely starts the same
|
||||
* way: "test" and "test2" score identically otherwise, and the shorter title
|
||||
* is the closer answer whichever of the two happens to be sooner.
|
||||
*/
|
||||
private fun coverage(text: String, spans: List<MatchSpan>): Double {
|
||||
if (text.isEmpty() || spans.isEmpty()) return 0.0
|
||||
return spans.sumOf { it.end - it.start }.toDouble() / text.length
|
||||
}
|
||||
|
||||
/** The best a single token does across the fields, or null if it is absent. */
|
||||
private fun bestScore(
|
||||
token: String,
|
||||
title: String,
|
||||
location: String?,
|
||||
description: String?,
|
||||
): Int? {
|
||||
val scores = listOfNotNull(
|
||||
kindIn(title, token)?.let { FIELD_TITLE * 10 + it },
|
||||
location?.let { kindIn(it, token) }?.let { FIELD_LOCATION * 10 + it },
|
||||
description?.let { kindIn(it, token) }?.let { FIELD_DESCRIPTION * 10 + it },
|
||||
)
|
||||
return scores.maxOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* How well [token] sits in [text]: at its very start, at the start of a word,
|
||||
* or inside one. Mid-word still counts — German compounds mean "termin" has
|
||||
* to keep finding "Zahnarzttermin" — it just ranks last.
|
||||
*/
|
||||
private fun kindIn(text: String, token: String): Int? {
|
||||
var best: Int? = null
|
||||
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||
while (index >= 0) {
|
||||
val kind = when {
|
||||
index == 0 -> KIND_FIELD_START
|
||||
!text[index - 1].isLetterOrDigit() -> KIND_WORD_START
|
||||
else -> KIND_MID_WORD
|
||||
}
|
||||
if (kind == KIND_FIELD_START) return KIND_FIELD_START
|
||||
if (best == null || kind > best) best = kind
|
||||
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||
val raw = mutableListOf<MatchSpan>()
|
||||
@@ -244,12 +255,7 @@ object EventSearchRanker {
|
||||
return DescriptionSnippet(text, rebased)
|
||||
}
|
||||
|
||||
private data class Scored(
|
||||
val hit: SearchHit,
|
||||
val weakest: Int,
|
||||
val total: Int,
|
||||
val titleCoverage: Double,
|
||||
/** Still on today's page or later, as opposed to a closed day. */
|
||||
val current: Boolean,
|
||||
)
|
||||
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||
val event: EventInstance get() = hit.event
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -79,6 +81,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
@@ -89,6 +92,7 @@ import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
@@ -393,6 +397,7 @@ private fun SearchTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
results: SearchUiState.Results,
|
||||
@@ -401,12 +406,44 @@ private fun SearchResults(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
val hits = results.hits
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// No horizontal inset here: GroupedRow already insets itself, and adding
|
||||
// another 16dp made the results narrower than every other list.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
results.results.months.forEach { month ->
|
||||
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
|
||||
SearchSectionHeader(text = monthLabel(month))
|
||||
}
|
||||
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
|
||||
}
|
||||
// Held back from the calendar above: these matched nothing in the name or
|
||||
// the place, only somewhere in the notes.
|
||||
if (results.results.inDescriptions.isNotEmpty()) {
|
||||
stickyHeader(key = "in-descriptions") {
|
||||
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
|
||||
}
|
||||
hitRows(
|
||||
results.results.inDescriptions,
|
||||
results,
|
||||
selection,
|
||||
inSelection,
|
||||
onEventClick,
|
||||
onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One section's rows, sharing a grouped-card run. */
|
||||
private fun LazyListScope.hitRows(
|
||||
hits: List<SearchHit>,
|
||||
results: SearchUiState.Results,
|
||||
selection: Set<Long>,
|
||||
inSelection: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = hits,
|
||||
@@ -432,6 +469,28 @@ private fun SearchResults(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pinned section label, styled like the agenda's day headers. */
|
||||
@Composable
|
||||
private fun SearchSectionHeader(text: String) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "August 2026" in the locale's own field order. */
|
||||
@Composable
|
||||
private fun monthLabel(month: SearchMonth): String {
|
||||
val locale = currentLocale()
|
||||
return remember(month.year, month.monthNumber, locale) {
|
||||
localizedDateFormatter(locale, "LLLLy")
|
||||
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -6,9 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearchRanker
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -44,9 +44,9 @@ sealed interface SearchUiState {
|
||||
/** A query ran but matched nothing. */
|
||||
data class Empty(val query: String) : SearchUiState
|
||||
|
||||
/** Matches, best first — see [de.jeanlucmakiola.calendula.domain.EventSearchRanker]. */
|
||||
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
|
||||
data class Results(
|
||||
val hits: List<SearchHit>,
|
||||
val results: SearchResults,
|
||||
/**
|
||||
* Calendars among the results that can't be written to (WebCal, birthday
|
||||
* mirrors, …). Their rows are excluded from selection rather than failing
|
||||
@@ -54,7 +54,7 @@ sealed interface SearchUiState {
|
||||
*/
|
||||
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||
) : SearchUiState {
|
||||
val events: List<EventInstance> get() = hits.map { it.event }
|
||||
val events: List<EventInstance> get() = results.allHits.map { it.event }
|
||||
|
||||
fun isDeletable(event: EventInstance): Boolean =
|
||||
event.calendarId !in readOnlyCalendarIds
|
||||
@@ -105,17 +105,18 @@ class SearchViewModel @Inject constructor(
|
||||
} else {
|
||||
val now = Clock.System.now()
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val hits = EventSearchRanker.rank(
|
||||
val results = EventSearch.search(
|
||||
candidates = repository.searchEvents(q),
|
||||
query = q,
|
||||
now = now,
|
||||
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
|
||||
zone = zone,
|
||||
)
|
||||
if (hits.isEmpty()) {
|
||||
if (results.isEmpty) {
|
||||
SearchUiState.Empty(q)
|
||||
} else {
|
||||
SearchUiState.Results(
|
||||
hits = hits,
|
||||
results = results,
|
||||
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
<item quantity="one">%d selected</item>
|
||||
<item quantity="other">%d selected</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Found in descriptions</string>
|
||||
<string name="search_selection_close">Cancel selection</string>
|
||||
<string name="search_select_all">Select all</string>
|
||||
<string name="search_delete_selected">Delete selected</string>
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Ranking and matching for search (#80 follow-up): where a query matched decides
|
||||
* the order, and matching folds case and splits on whitespace.
|
||||
*/
|
||||
class EventSearchRankerTest {
|
||||
|
||||
private val now = Instant.fromEpochMilliseconds(1_800_000_000_000L)
|
||||
|
||||
/** Midnight of [now]'s day in UTC — the ranker's current/past boundary. */
|
||||
private val todayStart = Instant.fromEpochMilliseconds(
|
||||
1_800_000_000_000L / 86_400_000L * 86_400_000L,
|
||||
)
|
||||
|
||||
private fun candidate(
|
||||
id: Long,
|
||||
title: String,
|
||||
location: String? = null,
|
||||
description: String? = null,
|
||||
startMillis: Long = now.toEpochMilliseconds() + 86_400_000L,
|
||||
) = SearchCandidate(
|
||||
event = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = Instant.fromEpochMilliseconds(startMillis),
|
||||
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
||||
isAllDay = false,
|
||||
color = 0,
|
||||
location = location,
|
||||
),
|
||||
description = description,
|
||||
)
|
||||
|
||||
private fun rank(candidates: List<SearchCandidate>, query: String) =
|
||||
EventSearchRanker.rank(candidates, query, now, todayStart)
|
||||
|
||||
private fun titlesFor(vararg candidates: SearchCandidate, query: String): List<String> =
|
||||
rank(candidates.toList(), query).map { it.event.title }
|
||||
|
||||
@Test
|
||||
fun `a title match outranks a description match even when it is further off`() {
|
||||
// The reported case: "protestantischer" in a holiday's description put it
|
||||
// above the events actually called Test, because it was sooner.
|
||||
val holiday = candidate(
|
||||
id = 1L,
|
||||
title = "Reformationstag",
|
||||
description = "Feiertag der protestantischer Kirchen",
|
||||
startMillis = now.toEpochMilliseconds() + 86_400_000L,
|
||||
)
|
||||
val real = candidate(
|
||||
id = 2L,
|
||||
title = "Test",
|
||||
startMillis = now.toEpochMilliseconds() + 30 * 86_400_000L,
|
||||
)
|
||||
|
||||
assertThat(titlesFor(holiday, real, query = "test"))
|
||||
.containsExactly("Test", "Reformationstag")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a word start outranks a mid-word match inside the same field`() {
|
||||
val midWord = candidate(1L, "Zahnarzttermin")
|
||||
val wordStart = candidate(2L, "Neuer Termin beim Amt")
|
||||
|
||||
assertThat(titlesFor(midWord, wordStart, query = "termin"))
|
||||
.containsExactly("Neuer Termin beim Amt", "Zahnarzttermin")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mid-word match is still found, so compounds keep working`() {
|
||||
val compound = candidate(1L, "Zahnarzttermin")
|
||||
|
||||
assertThat(titlesFor(compound, query = "termin")).containsExactly("Zahnarzttermin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the field ranking runs title over location over description`() {
|
||||
val inDescription = candidate(1L, "Frühstück", description = "im Büro besprechen")
|
||||
val inLocation = candidate(2L, "Frühstück", location = "Büro")
|
||||
val inTitle = candidate(3L, "Büro aufräumen")
|
||||
|
||||
assertThat(titlesFor(inDescription, inLocation, inTitle, query = "büro"))
|
||||
.containsExactly("Büro aufräumen", "Frühstück", "Frühstück")
|
||||
.inOrder()
|
||||
// The location hit ranks above the description one.
|
||||
val ranked = rank(listOf(inDescription, inLocation, inTitle), "büro")
|
||||
assertThat(ranked.map { it.event.eventId }).containsExactly(3L, 2L, 1L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case folds beyond ASCII`() {
|
||||
val upper = candidate(1L, "ÄRZTE Termin")
|
||||
|
||||
assertThat(titlesFor(upper, query = "ärzte")).containsExactly("ÄRZTE Termin")
|
||||
assertThat(titlesFor(candidate(2L, "ärzte"), query = "ÄRZTE")).containsExactly("ärzte")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every token has to match, in any field and any order`() {
|
||||
val both = candidate(1L, "Zahnarzt", description = "Termin bestätigen")
|
||||
val onlyOne = candidate(2L, "Zahnarzt")
|
||||
|
||||
val ranked = rank(listOf(both, onlyOne), "termin zahnarzt")
|
||||
|
||||
assertThat(ranked.map { it.event.eventId }).containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event ranks by the weakest field the query needed`() {
|
||||
// Both tokens in the title beats one token stranded in the description.
|
||||
val allInTitle = candidate(1L, "Zahnarzt Termin")
|
||||
val split = candidate(2L, "Zahnarzt", description = "Termin bestätigen")
|
||||
|
||||
assertThat(titlesFor(split, allInTitle, query = "zahnarzt termin"))
|
||||
.containsExactly("Zahnarzt Termin", "Zahnarzt")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exact title beats one that only starts the same way, within the day`() {
|
||||
// Reported: "test" at 19:00 had already passed and "test2" at 23:00 was
|
||||
// still to come, so the date key put the inexact match first. Both are
|
||||
// today's business, so exactness decides.
|
||||
val exact = candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 3 * 3_600_000L)
|
||||
val longer = candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 3_600_000L)
|
||||
|
||||
assertThat(titlesFor(longer, exact, query = "test"))
|
||||
.containsExactly("test", "test2")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a match still to come beats an equally scoring one from a closed day`() {
|
||||
// Across days the exactness is not enough: "test2" tomorrow is the one
|
||||
// that can still be acted on, "test" yesterday is history.
|
||||
val yesterday =
|
||||
candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 86_400_000L)
|
||||
val tomorrow =
|
||||
candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 86_400_000L)
|
||||
|
||||
assertThat(titlesFor(yesterday, tomorrow, query = "test"))
|
||||
.containsExactly("test2", "test")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hit that has already finished is marked past, including earlier today`() {
|
||||
val earlierToday =
|
||||
candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 3 * 3_600_000L)
|
||||
val laterToday =
|
||||
candidate(2L, "test", startMillis = now.toEpochMilliseconds() + 3_600_000L)
|
||||
|
||||
val ranked = rank(listOf(earlierToday, laterToday), "test").associateBy { it.event.eventId }
|
||||
|
||||
assertThat(ranked.getValue(1L).isPast).isTrue()
|
||||
assertThat(ranked.getValue(2L).isPast).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shorter title wins when both merely contain the query`() {
|
||||
val shorter = candidate(1L, "Team Meeting")
|
||||
val longer = candidate(2L, "Team Meeting with the whole department")
|
||||
|
||||
assertThat(titlesFor(longer, shorter, query = "meeting"))
|
||||
.containsExactly("Team Meeting", "Team Meeting with the whole department")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ties keep the nearest-first order, upcoming before past`() {
|
||||
val soon = candidate(1L, "Test", startMillis = now.toEpochMilliseconds() + 86_400_000L)
|
||||
val later = candidate(2L, "Test", startMillis = now.toEpochMilliseconds() + 5 * 86_400_000L)
|
||||
val recentPast =
|
||||
candidate(3L, "Test", startMillis = now.toEpochMilliseconds() - 86_400_000L)
|
||||
val longAgo =
|
||||
candidate(4L, "Test", startMillis = now.toEpochMilliseconds() - 90 * 86_400_000L)
|
||||
|
||||
val ranked = rank(listOf(longAgo, later, recentPast, soon), "test")
|
||||
|
||||
assertThat(ranked.map { it.event.eventId }).containsExactly(1L, 2L, 3L, 4L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title carries the spans to highlight, merged where they overlap`() {
|
||||
val event = candidate(1L, "Test the tester")
|
||||
|
||||
val hit = rank(listOf(event), "test").single()
|
||||
|
||||
assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a description snippet is excerpted around its match`() {
|
||||
val long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
|
||||
val event = candidate(1L, "Notiz", description = long)
|
||||
|
||||
val snippet = rank(listOf(event), "geheimwort")
|
||||
.single()
|
||||
.descriptionSnippet
|
||||
|
||||
requireNotNull(snippet)
|
||||
assertThat(snippet.text).contains("geheimwort")
|
||||
assertThat(snippet.text.length).isLessThan(long.length)
|
||||
assertThat(snippet.text).startsWith("…")
|
||||
// The span points at the match inside the excerpt, not the full text.
|
||||
val span = snippet.spans.single()
|
||||
assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title-only match carries no description snippet`() {
|
||||
val event = candidate(1L, "Test", description = "nothing relevant here")
|
||||
|
||||
assertThat(rank(listOf(event), "test").single().descriptionSnippet)
|
||||
.isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-line description matches and snippets as one line`() {
|
||||
val event = candidate(1L, "Notiz", description = "erste Zeile\n\n zweite Zeile")
|
||||
|
||||
val snippet = rank(listOf(event), "zweite").single().descriptionSnippet
|
||||
|
||||
requireNotNull(snippet)
|
||||
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank query matches nothing`() {
|
||||
assertThat(rank(listOf(candidate(1L, "Test")), " ")).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Search matching and how the results are laid out (#80 follow-up): a calendar
|
||||
* of months rather than a ranking, with description-only hits held back.
|
||||
*/
|
||||
class EventSearchTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
/** 2027-01-15T08:00Z — mid-month and mid-day, so both sides of each are testable. */
|
||||
private val now = Instant.parse("2027-01-15T08:00:00Z")
|
||||
private val todayStart = Instant.parse("2027-01-15T00:00:00Z")
|
||||
|
||||
private var nextId = 1L
|
||||
|
||||
private fun candidate(
|
||||
title: String,
|
||||
start: String = "2027-01-20T09:00:00Z",
|
||||
location: String? = null,
|
||||
description: String? = null,
|
||||
): SearchCandidate {
|
||||
val begin = Instant.parse(start)
|
||||
return SearchCandidate(
|
||||
event = EventInstance(
|
||||
instanceId = nextId,
|
||||
eventId = nextId++,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = begin,
|
||||
end = begin.plus(kotlin.time.Duration.parse("1h")),
|
||||
isAllDay = false,
|
||||
color = 0,
|
||||
location = location,
|
||||
),
|
||||
description = description,
|
||||
)
|
||||
}
|
||||
|
||||
private fun search(vararg candidates: SearchCandidate, query: String): SearchResults =
|
||||
EventSearch.search(candidates.toList(), query, now, todayStart, zone)
|
||||
|
||||
private fun datedTitles(results: SearchResults): List<String> =
|
||||
results.months.flatMap { month -> month.hits.map { it.event.title } }
|
||||
|
||||
@Test
|
||||
fun `hits are grouped into months, this one first, then ahead, then back`() {
|
||||
val thisMonth = candidate("Test A", start = "2027-01-20T09:00:00Z")
|
||||
val nextMonth = candidate("Test B", start = "2027-02-03T09:00:00Z")
|
||||
val lastMonth = candidate("Test C", start = "2026-12-10T09:00:00Z")
|
||||
val yearBefore = candidate("Test D", start = "2026-03-10T09:00:00Z")
|
||||
|
||||
val results = search(yearBefore, nextMonth, lastMonth, thisMonth, query = "test")
|
||||
|
||||
assertThat(results.months.map { it.year to it.monthNumber })
|
||||
.containsExactly(2027 to 1, 2027 to 2, 2026 to 12, 2026 to 3)
|
||||
.inOrder()
|
||||
assertThat(datedTitles(results)).containsExactly("Test A", "Test B", "Test C", "Test D")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inside a month what is still to come comes before what has passed`() {
|
||||
// Both sides of today share one January card rather than two headers.
|
||||
val earlierThisMonth = candidate("Test past", start = "2027-01-05T09:00:00Z")
|
||||
val laterThisMonth = candidate("Test soon", start = "2027-01-20T09:00:00Z")
|
||||
|
||||
val results = search(earlierThisMonth, laterThisMonth, query = "test")
|
||||
|
||||
assertThat(results.months).hasSize(1)
|
||||
assertThat(datedTitles(results)).containsExactly("Test soon", "Test past").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event still running today keeps its place among the upcoming ones`() {
|
||||
// Reported: "test" at 19:00 having passed must not fall behind "test2" at
|
||||
// 23:00 — the day is the boundary, not the moment.
|
||||
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
|
||||
val laterToday = candidate("test2", start = "2027-01-15T20:00:00Z")
|
||||
|
||||
val results = search(laterToday, earlierToday, query = "test")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("test", "test2").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a match tomorrow comes before one from yesterday`() {
|
||||
val yesterday = candidate("test", start = "2027-01-14T09:00:00Z")
|
||||
val tomorrow = candidate("test2", start = "2027-01-16T09:00:00Z")
|
||||
|
||||
val results = search(yesterday, tomorrow, query = "test")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("test2", "test").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a description-only hit is held back from the dated months`() {
|
||||
// The original complaint: "protestantischer" in a holiday's notes landed
|
||||
// among the events actually named Test.
|
||||
val holiday = candidate(
|
||||
"Reformationstag",
|
||||
start = "2027-01-16T09:00:00Z",
|
||||
description = "Feiertag der protestantischer Kirchen",
|
||||
)
|
||||
val real = candidate("Test", start = "2027-06-01T09:00:00Z")
|
||||
|
||||
val results = search(holiday, real, query = "test")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("Test")
|
||||
assertThat(results.inDescriptions.map { it.event.title }).containsExactly("Reformationstag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a location match stays in the dated months`() {
|
||||
val located = candidate("Frühstück", location = "Büro")
|
||||
|
||||
val results = search(located, query = "büro")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("Frühstück")
|
||||
assertThat(results.inDescriptions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event named for one token stays dated even if another is in the notes`() {
|
||||
val mixed = candidate("Zahnarzt", description = "Termin bestätigen")
|
||||
|
||||
val results = search(mixed, query = "zahnarzt termin")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("Zahnarzt")
|
||||
assertThat(results.inDescriptions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every token has to match, in any field and any order`() {
|
||||
val both = candidate("Zahnarzt", description = "Termin bestätigen")
|
||||
val onlyOne = candidate("Zahnarzt")
|
||||
|
||||
val results = search(both, onlyOne, query = "termin zahnarzt")
|
||||
|
||||
assertThat(results.allHits.map { it.event.eventId }).containsExactly(both.event.eventId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mid-word match is still found, so compounds keep working`() {
|
||||
val compound = candidate("Zahnarzttermin")
|
||||
|
||||
assertThat(datedTitles(search(compound, query = "termin"))).containsExactly("Zahnarzttermin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case folds beyond ASCII`() {
|
||||
assertThat(datedTitles(search(candidate("ÄRZTE Termin"), query = "ärzte")))
|
||||
.containsExactly("ÄRZTE Termin")
|
||||
assertThat(datedTitles(search(candidate("ärzte"), query = "ÄRZTE")))
|
||||
.containsExactly("ärzte")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event is filed by its own day, not by UTC`() {
|
||||
// Behind UTC an all-day event sits at midnight UTC, which is the previous
|
||||
// month locally (#82) — the month has to come from the span's first day.
|
||||
val newYear = SearchCandidate(
|
||||
event = EventInstance(
|
||||
instanceId = 99L,
|
||||
eventId = 99L,
|
||||
calendarId = 1L,
|
||||
title = "Test holiday",
|
||||
start = Instant.parse("2027-02-01T00:00:00Z"),
|
||||
end = Instant.parse("2027-02-02T00:00:00Z"),
|
||||
isAllDay = true,
|
||||
color = 0,
|
||||
location = null,
|
||||
),
|
||||
)
|
||||
|
||||
val results = EventSearch.search(
|
||||
listOf(newYear),
|
||||
"test",
|
||||
now,
|
||||
todayStart,
|
||||
TimeZone.of("America/New_York"),
|
||||
)
|
||||
|
||||
assertThat(results.months.single().let { it.year to it.monthNumber }).isEqualTo(2027 to 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hit that has already finished is marked past, including earlier today`() {
|
||||
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
|
||||
val laterToday = candidate("test", start = "2027-01-15T20:00:00Z")
|
||||
|
||||
val hits = search(earlierToday, laterToday, query = "test")
|
||||
.allHits
|
||||
.associateBy { it.event.eventId }
|
||||
|
||||
assertThat(hits.getValue(earlierToday.event.eventId).isPast).isTrue()
|
||||
assertThat(hits.getValue(laterToday.event.eventId).isPast).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title carries the spans to highlight, merged where they overlap`() {
|
||||
val hit = search(candidate("Test the tester"), query = "test").allHits.single()
|
||||
|
||||
assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a description snippet is excerpted around its match`() {
|
||||
val long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
|
||||
|
||||
val snippet = search(candidate("Notiz", description = long), query = "geheimwort")
|
||||
.allHits
|
||||
.single()
|
||||
.descriptionSnippet
|
||||
|
||||
requireNotNull(snippet)
|
||||
assertThat(snippet.text).contains("geheimwort")
|
||||
assertThat(snippet.text.length).isLessThan(long.length)
|
||||
assertThat(snippet.text).startsWith("…")
|
||||
val span = snippet.spans.single()
|
||||
assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title-only match carries no description snippet`() {
|
||||
val hit = search(candidate("Test", description = "nothing relevant here"), query = "test")
|
||||
.allHits
|
||||
.single()
|
||||
|
||||
assertThat(hit.descriptionSnippet).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-line description matches and snippets as one line`() {
|
||||
val snippet = search(
|
||||
candidate("Notiz", description = "erste Zeile\n\n zweite Zeile"),
|
||||
query = "zweite",
|
||||
).allHits.single().descriptionSnippet
|
||||
|
||||
requireNotNull(snippet)
|
||||
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank query matches nothing`() {
|
||||
assertThat(search(candidate("Test"), query = " ").isEmpty).isTrue()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user