From b43d9117abee9fc58151086db39d389b93db335b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 23 Sep 2026 11:01:42 +0200 Subject: [PATCH] refactor(text): lift diacritic folding out of the zone search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../clockula/domain/text/TextFolding.kt | 33 ++++++++++ .../clockula/domain/worldclock/ZoneSearch.kt | 18 +++--- .../clockula/domain/text/TextFoldingTest.kt | 63 +++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/text/TextFolding.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/text/TextFoldingTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/text/TextFolding.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/text/TextFolding.kt new file mode 100644 index 0000000..22817cd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/text/TextFolding.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt index 2137e70..076bdff 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt @@ -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 = diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/text/TextFoldingTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/text/TextFoldingTest.kt new file mode 100644 index 0000000..924270e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/text/TextFoldingTest.kt @@ -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)) + } +}