diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 1cdf8f7..892977d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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 @@ -591,17 +591,17 @@ class AndroidCalendarDataSource @Inject constructor( override fun searchEvents(query: String): List { 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index a384292..0599d78 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt index 194c871..c77d811 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt @@ -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, +) + /** - * 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, + val inDescriptions: List, +) { + val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty() + + /** Every hit, whichever section it sits in — for select-all and delete. */ + val allHits: List 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, query: String, now: Instant, todayStart: Instant, - ): List { + 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 { it.weakest } - .thenByDescending { it.total } - .thenBy { if (it.current) 0 else 1 } - .thenByDescending { it.titleCoverage } - .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 } + val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now) } + val order = compareBy { if (it.event.end >= todayStart) 0 else 1 } + .thenComparator { a, b -> + // 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) + } + } + + val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly } + return SearchResults( + months = groupByMonth(dated, zone, todayStart), + inDescriptions = inDescriptions.map { it.hit }, + ) } - private fun score( + /** + * 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, + zone: TimeZone, + todayStart: Instant, + ): List { + val currentKey = monthKey(todayStart.toLocalDateTime(zone).date) + return dated + .groupBy { monthKey(it.event.spanFirstDay(zone)) } + .entries + .sortedWith( + compareBy>> { + 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, 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): 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): List { val raw = mutableListOf() @@ -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 + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index f57c453..123dc65 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -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,39 +406,93 @@ 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), ) { - itemsIndexed( - items = hits, - key = { _, hit -> hit.event.eventId }, - ) { index, hit -> - val event = hit.event - val deletable = results.isDeletable(event) - SearchResultRow( - hit = hit, - position = positionOf(index, hits.size), - modifier = animateItemMotion(), - selected = event.eventId in selection, - // A read-only calendar's row can't join a batch, so in selection - // mode it goes quiet rather than offering a checkbox that fails. - inSelection = inSelection, - selectable = deletable, - onClick = when { - inSelection && deletable -> ({ onToggle(event.eventId) }) - inSelection -> null - else -> ({ onEventClick(event) }) - }, - onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection }, + 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, + results: SearchUiState.Results, + selection: Set, + inSelection: Boolean, + onEventClick: (EventInstance) -> Unit, + onToggle: (Long) -> Unit, +) { + itemsIndexed( + items = hits, + key = { _, hit -> hit.event.eventId }, + ) { index, hit -> + val event = hit.event + val deletable = results.isDeletable(event) + SearchResultRow( + hit = hit, + position = positionOf(index, hits.size), + modifier = animateItemMotion(), + selected = event.eventId in selection, + // A read-only calendar's row can't join a batch, so in selection + // mode it goes quiet rather than offering a checkbox that fails. + inSelection = inSelection, + selectable = deletable, + onClick = when { + inSelection && deletable -> ({ onToggle(event.eventId) }) + inSelection -> null + else -> ({ onEventClick(event) }) + }, + onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection }, + ) + } +} + +/** 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 private fun SearchResultRow( hit: SearchHit, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt index 6850570..5eacddf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -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, + 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 = emptySet(), ) : SearchUiState { - val events: List get() = hits.map { it.event } + val events: List 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(), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c8efdc..51ed4ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -323,6 +323,7 @@ %d selected %d selected + Found in descriptions Cancel selection Select all Delete selected diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt deleted file mode 100644 index 7271bf5..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt +++ /dev/null @@ -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, query: String) = - EventSearchRanker.rank(candidates, query, now, todayStart) - - private fun titlesFor(vararg candidates: SearchCandidate, query: String): List = - 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() - } -} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt new file mode 100644 index 0000000..78e9c7e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt @@ -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 = + 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() + } +}