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 6f0e372..7c5324d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt @@ -42,6 +42,8 @@ data class SearchHit( * 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. + * Equal scores are separated by how much of the title the match covers, so an + * exact title beats one that merely begins the same way, and only then by date. */ object EventSearchRanker { @@ -86,6 +88,7 @@ object EventSearchRanker { .sortedWith( compareByDescending { it.weakest } .thenByDescending { it.total } + .thenByDescending { it.titleCoverage } .thenBy { if (it.hit.event.end >= now) 0 else 1 } .thenComparator { a, b -> val aStart = a.hit.event.start @@ -121,10 +124,11 @@ object EventSearchRanker { } val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty() + val titleSpans = spansIn(title, tokens) return Scored( hit = SearchHit( event = candidate.event, - titleSpans = spansIn(title, tokens), + titleSpans = titleSpans, locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(), descriptionSnippet = description ?.takeIf { descriptionSpans.isNotEmpty() } @@ -132,9 +136,21 @@ object EventSearchRanker { ), weakest = weakest, total = total, + titleCoverage = coverage(title, titleSpans), ) } + /** + * 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, @@ -217,5 +233,10 @@ object EventSearchRanker { return DescriptionSnippet(text, rebased) } - private data class Scored(val hit: SearchHit, val weakest: Int, val total: Int) + private data class Scored( + val hit: SearchHit, + val weakest: Int, + val total: Int, + val titleCoverage: Double, + ) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt index 20346a2..db99ab2 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt @@ -121,6 +121,28 @@ class EventSearchRankerTest { .inOrder() } + @Test + fun `an exact title beats one that only starts the same way, whenever it is`() { + // 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. + 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 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)