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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user