refactor(text): lift diacritic folding out of the zone search

M8 folded text so "sao" would find São Paulo. M9 needs the same fold to match
an alarm by its label, so the fold moves to `domain/text/` and the zone search
calls it rather than owning it.
This commit is contained in:
2026-09-23 11:01:42 +02:00
parent 74a6295543
commit b43d9117ab
3 changed files with 103 additions and 11 deletions
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.domain.text
import java.text.Normalizer
import java.util.Locale
/**
* One copy of "fold a string for comparison", shared by the zone search and the
* `ALARM_SEARCH_MODE_LABEL` match — two surfaces that must never disagree about
* whether "cafe" finds "Café" (M9 D18).
*/
object TextFolding {
/** The combining marks NFD leaves behind once the base letter is separated out. */
private val COMBINING_MARKS = Regex("\\p{Mn}+")
/** NFD, combining marks stripped, lowercased through `Locale.ROOT`. */
fun fold(text: String): String =
COMBINING_MARKS
.replace(Normalizer.normalize(text, Normalizer.Form.NFD), "")
// `Locale.ROOT`, because a Turkish default locale turns "I" into a
// dotless "ı" and the search silently stops matching for one set of
// users.
.lowercase(Locale.ROOT)
.trim()
/** Folded substring match. A blank [needle] is **false**, never "matches everything". */
fun containsFolded(haystack: String, needle: String): Boolean {
val wanted = fold(needle)
// A needle that matched everything would dismiss every alarm in the app.
if (wanted.isEmpty()) return false
return fold(haystack).contains(wanted)
}
}
@@ -1,22 +1,18 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import java.text.Normalizer
import java.util.Locale
import de.jeanlucmakiola.clockula.domain.text.TextFolding
object ZoneSearch {
/** Every run of characters that is neither a letter nor a digit — one word break. */
private val WORD_BREAK = Regex("[^\\p{L}\\p{N}]+")
/** The combining marks NFD leaves behind once the base letter is separated out. */
private val COMBINING_MARKS = Regex("\\p{Mn}+")
/** NFD, combining marks stripped, lowercased through `Locale.ROOT`, trimmed. */
fun fold(text: String): String =
COMBINING_MARKS
.replace(Normalizer.normalize(text, Normalizer.Form.NFD), "")
.lowercase(Locale.ROOT)
.trim()
/**
* NFD, combining marks stripped, lowercased through `Locale.ROOT`, trimmed.
* Delegated to [TextFolding] since M9: the label search folds the same way,
* and a second copy is how two surfaces start disagreeing (M9 D18).
*/
fun fold(text: String): String = TextFolding.fold(text)
/** [fold] split on every run of non-letter, non-digit characters; blanks dropped. */
fun words(text: String): List<String> =
@@ -0,0 +1,63 @@
package de.jeanlucmakiola.clockula.domain.text
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.worldclock.ZoneSearch
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* One copy of the folding, so "café" cannot match on the zone picker and miss
* on `ALARM_SEARCH_MODE_LABEL`. The lowercasing goes through `Locale.ROOT`
* because a Turkish default locale turns "I" into a dotless "ı" and a search
* silently stops matching for one set of users (M9 D18).
*/
class TextFoldingTest {
@Test
fun `folding strips diacritics and lowercases`() {
assertThat(TextFolding.fold("São Paulo")).isEqualTo("sao paulo")
}
@Test
fun `folding lowercases through the root locale, whatever the default is`() {
val original = Locale.getDefault()
Locale.setDefault(Locale.forLanguageTag("tr"))
try {
assertThat(TextFolding.fold("I")).isEqualTo("i")
} finally {
Locale.setDefault(original)
}
}
@Test
fun `a substring match ignores case`() {
assertThat(TextFolding.containsFolded("Morning gym", "GYM")).isTrue()
}
@Test
fun `a blank needle matches nothing`() {
val matches = listOf(
TextFolding.containsFolded("Morning gym", " "),
TextFolding.containsFolded("Morning gym", ""),
)
assertThat(matches).containsExactly(false, false)
}
@Test
fun `folding is applied to both sides of the match`() {
val matches = listOf(
TextFolding.containsFolded("Café", "cafe"),
TextFolding.containsFolded("cafe", "café"),
)
assertThat(matches).containsExactly(true, true)
}
@Test
fun `the zone search folds through the same function it always did`() {
val samples = listOf("São Paulo", "Köln", "ISTANBUL", "Ho Chi Minh", "Ørsted")
assertThat(samples.map(TextFolding::fold)).isEqualTo(samples.map(ZoneSearch::fold))
}
}