Migrate osm opening hours parsing to external library (#900)
* Fix: don't treat PH OpeningHours with specified time as `everyDay` * migrate to osm-opening-hours, add tests for openinghours parsing * Style nit * implement 'PH,Su off' * implement SpecificWeekdays * decrypt source code (a little) * fix constructor call in OpeningHoursTest.kt * lint * fix negative durations, add test for LastNth + test to combine everything implemented * add copyright note for osm-opening-hours * remove unused library
This commit is contained in:
parent
7d490f2179
commit
16637265fb
@ -228,4 +228,12 @@ val OpenSourceLicenses = arrayOf(
|
|||||||
url = "https://github.com/aallam/string-similarity-kotlin",
|
url = "https://github.com/aallam/string-similarity-kotlin",
|
||||||
copyrightNote = "Copyright (c) 2023 Mouaad Aallam",
|
copyrightNote = "Copyright (c) 2023 Mouaad Aallam",
|
||||||
),
|
),
|
||||||
|
OpenSourceLibrary(
|
||||||
|
name = "osm-opening-hours",
|
||||||
|
description = "Kotlin multiplatform library to parse OpenStreetMap opening hours",
|
||||||
|
licenseName = R.string.mit_license_name,
|
||||||
|
licenseText = R.raw.license_mit,
|
||||||
|
url = "https://github.com/westnordost/osm-opening-hours",
|
||||||
|
copyrightNote = "Copyright (c) 2024 Tobias Zwick",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
@ -18,8 +18,7 @@ class OpeningScheduleTest(val date: LocalDateTime, val expected: Boolean) {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun isOpen() {
|
fun isOpen() {
|
||||||
val openingSchedule = OpeningSchedule(
|
val openingSchedule = OpeningSchedule.Hours(
|
||||||
isTwentyFourSeven = false,
|
|
||||||
/**
|
/**
|
||||||
* Monday: 18:00 - Tue. 01:00
|
* Monday: 18:00 - Tue. 01:00
|
||||||
* Tuesday: 10:00 - 00:00
|
* Tuesday: 10:00 - 00:00
|
||||||
|
|||||||
18
core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt
Normal file
18
core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package de.mm20.launcher2.ktx
|
||||||
|
|
||||||
|
fun <T,V> Iterable<T>.flatMapNotNull(transform: (T) -> Iterable<V>?) : List<V> {
|
||||||
|
val destination = mutableListOf<V>()
|
||||||
|
for (it in this) {
|
||||||
|
val transformed = transform(it) ?: continue
|
||||||
|
destination.addAll(transformed)
|
||||||
|
}
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T,V> Iterable<Pair<T, V>>.toMultiMap() : Map<T, List<V>> {
|
||||||
|
val destination = mutableMapOf<T, MutableList<V>>()
|
||||||
|
for ((k, v) in this) {
|
||||||
|
destination.getOrPut(k) { mutableListOf() } += v
|
||||||
|
}
|
||||||
|
return destination
|
||||||
|
}
|
||||||
@ -11,3 +11,7 @@ fun <T> List<T>.randomElementOrNull(): T? {
|
|||||||
if (isEmpty()) return null
|
if (isEmpty()) return null
|
||||||
return get(Random().nextInt(size))
|
return get(Random().nextInt(size))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T> List<T>?.ifNullOrEmpty(block: () -> List<T>): List<T> {
|
||||||
|
return if (this.isNullOrEmpty()) block() else this
|
||||||
|
}
|
||||||
|
|||||||
5
core/ktx/src/main/java/de/mm20/launcher2/ktx/Pair.kt
Normal file
5
core/ktx/src/main/java/de/mm20/launcher2/ktx/Pair.kt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
package de.mm20.launcher2.ktx
|
||||||
|
|
||||||
|
fun <A, B> Pair<A, A>.map(transform: (A) -> B): Pair<B, B> = transform(first) to transform(second)
|
||||||
|
|
||||||
|
fun <A, B, C> Pair<A, B>.into(transform: (A, B) -> C): C = transform(first, second)
|
||||||
@ -50,6 +50,8 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
||||||
|
implementation(libs.osmopeninghours)
|
||||||
|
|
||||||
implementation(project(":core:preferences"))
|
implementation(project(":core:preferences"))
|
||||||
implementation(project(":core:base"))
|
implementation(project(":core:base"))
|
||||||
implementation(project(":core:ktx"))
|
implementation(project(":core:ktx"))
|
||||||
@ -57,4 +59,5 @@ dependencies {
|
|||||||
implementation(project(":core:crashreporter"))
|
implementation(project(":core:crashreporter"))
|
||||||
implementation(project(":core:devicepose"))
|
implementation(project(":core:devicepose"))
|
||||||
implementation(project(":libs:address-formatter"))
|
implementation(project(":libs:address-formatter"))
|
||||||
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
package de.mm20.launcher2.locations.providers.openstreetmaps
|
package de.mm20.launcher2.locations.providers.openstreetmaps
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import de.mm20.launcher2.ktx.flatMapNotNull
|
||||||
|
import de.mm20.launcher2.ktx.ifNullOrEmpty
|
||||||
|
import de.mm20.launcher2.ktx.into
|
||||||
|
import de.mm20.launcher2.ktx.map
|
||||||
|
import de.mm20.launcher2.ktx.toMultiMap
|
||||||
import de.mm20.launcher2.locations.OsmLocationSerializer
|
import de.mm20.launcher2.locations.OsmLocationSerializer
|
||||||
import de.mm20.launcher2.openstreetmaps.R
|
import de.mm20.launcher2.openstreetmaps.R
|
||||||
import de.mm20.launcher2.search.Location
|
import de.mm20.launcher2.search.Location
|
||||||
@ -14,16 +18,34 @@ import de.mm20.launcher2.search.location.Departure
|
|||||||
import de.mm20.launcher2.search.location.LocationIcon
|
import de.mm20.launcher2.search.location.LocationIcon
|
||||||
import de.mm20.launcher2.search.location.OpeningHours
|
import de.mm20.launcher2.search.location.OpeningHours
|
||||||
import de.mm20.launcher2.search.location.OpeningSchedule
|
import de.mm20.launcher2.search.location.OpeningSchedule
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import de.westnordost.osm_opening_hours.model.ClockTime
|
||||||
|
import de.westnordost.osm_opening_hours.model.ExtendedClockTime
|
||||||
|
import de.westnordost.osm_opening_hours.model.LastNth
|
||||||
|
import de.westnordost.osm_opening_hours.model.MonthRange
|
||||||
|
import de.westnordost.osm_opening_hours.model.Nth
|
||||||
|
import de.westnordost.osm_opening_hours.model.NthRange
|
||||||
|
import de.westnordost.osm_opening_hours.model.Range
|
||||||
|
import de.westnordost.osm_opening_hours.model.SingleMonth
|
||||||
|
import de.westnordost.osm_opening_hours.model.SpecificWeekdays
|
||||||
|
import de.westnordost.osm_opening_hours.model.StartingAtYear
|
||||||
|
import de.westnordost.osm_opening_hours.model.TimeSpan
|
||||||
|
import de.westnordost.osm_opening_hours.model.TimesSelector
|
||||||
|
import de.westnordost.osm_opening_hours.model.TwentyFourSeven
|
||||||
|
import de.westnordost.osm_opening_hours.model.Weekday
|
||||||
|
import de.westnordost.osm_opening_hours.model.WeekdayRange
|
||||||
|
import de.westnordost.osm_opening_hours.model.WeekdaysSelector
|
||||||
|
import de.westnordost.osm_opening_hours.model.Year
|
||||||
|
import de.westnordost.osm_opening_hours.model.YearRange
|
||||||
|
import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.woheller69.AndroidAddressFormatter.OsmAddressFormatter
|
import org.woheller69.AndroidAddressFormatter.OsmAddressFormatter
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.temporal.ChronoUnit
|
||||||
import java.time.format.DateTimeParseException
|
import java.time.temporal.TemporalAdjusters
|
||||||
import java.time.format.ResolverStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@ -281,6 +303,7 @@ private fun Map<String, String>.categorize(context: Context): Pair<String?, Loca
|
|||||||
"skiing" with (R.string.poi_category_skiing to LocationIcon.Skiing)
|
"skiing" with (R.string.poi_category_skiing to LocationIcon.Skiing)
|
||||||
"cricket" with (R.string.poi_category_cricket to LocationIcon.Cricket)
|
"cricket" with (R.string.poi_category_cricket to LocationIcon.Cricket)
|
||||||
}
|
}
|
||||||
|
|
||||||
"golf_course" -> R.string.poi_category_golf to LocationIcon.Golf
|
"golf_course" -> R.string.poi_category_golf to LocationIcon.Golf
|
||||||
"park" -> R.string.poi_category_park to LocationIcon.Park
|
"park" -> R.string.poi_category_park to LocationIcon.Park
|
||||||
else -> null
|
else -> null
|
||||||
@ -308,187 +331,181 @@ private fun Map<String, String>.categorize(context: Context): Pair<String?, Loca
|
|||||||
return context.resources.getString(rid) to icon
|
return context.resources.getString(rid) to icon
|
||||||
}
|
}
|
||||||
|
|
||||||
// allow for 24:00 to be part of the same day
|
internal fun parseOpeningSchedule(
|
||||||
// https://stackoverflow.com/a/31113244
|
it: String?,
|
||||||
private val DATE_TIME_FORMATTER =
|
localTime: LocalDateTime = LocalDateTime.now()
|
||||||
DateTimeFormatter.ISO_LOCAL_TIME.withResolverStyle(ResolverStyle.SMART)
|
): OpeningSchedule? {
|
||||||
|
val parsed = it?.toOpeningHoursOrNull(lenient = true) ?: return null
|
||||||
|
|
||||||
private val timeRegex by lazy {
|
if (parsed.rules.singleOrNull()?.selector is TwentyFourSeven)
|
||||||
Regex(
|
|
||||||
"""^(?:\d{2}:\d{2}-?){2}$""",
|
|
||||||
RegexOption.IGNORE_CASE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
private val singleDayRegex by lazy {
|
|
||||||
Regex(
|
|
||||||
"""^[mtwfsp][ouehra]$""",
|
|
||||||
RegexOption.IGNORE_CASE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
private val dayRangeRegex by lazy {
|
|
||||||
Regex(
|
|
||||||
"""^[mtwfsp][ouehra]-[mtwfsp][ouehra]$""",
|
|
||||||
RegexOption.IGNORE_CASE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val daysOfWeek = enumValues<DayOfWeek>().toList().toImmutableList()
|
|
||||||
|
|
||||||
private val twentyFourSeven = daysOfWeek.map {
|
|
||||||
OpeningHours(
|
|
||||||
dayOfWeek = it,
|
|
||||||
startTime = LocalTime.MIDNIGHT,
|
|
||||||
duration = Duration.ofDays(1)
|
|
||||||
)
|
|
||||||
}.toImmutableList()
|
|
||||||
|
|
||||||
// If this is not sufficient, resort to implementing https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification
|
|
||||||
// or port https://github.com/opening-hours/opening_hours.js
|
|
||||||
internal fun parseOpeningSchedule(it: String?): OpeningSchedule? {
|
|
||||||
if (it.isNullOrBlank()) return null
|
|
||||||
|
|
||||||
val openingHours = mutableListOf<OpeningHours>()
|
|
||||||
|
|
||||||
// e.g.
|
|
||||||
// "Mo-Sa 11:00-14:00, 17:00-23:00; Su 11:00-23:00"
|
|
||||||
// "Mo-Sa 11:00-21:00; PH,Su off"
|
|
||||||
// "Mo-Th 10:00-24:00, Fr,Sa 10:00-05:00, PH,Su 12:00-22:00"
|
|
||||||
var blocks =
|
|
||||||
it.split(',', ';', ' ').mapNotNull { if (it.isBlank()) null else it.trim() }
|
|
||||||
|
|
||||||
if (blocks.first() == "24/7")
|
|
||||||
return OpeningSchedule.TwentyFourSeven
|
return OpeningSchedule.TwentyFourSeven
|
||||||
|
|
||||||
fun dayOfWeekFromString(it: String): DayOfWeek? = when (it.lowercase()) {
|
val rangeRules = parsed.rules.mapNotNull { it.selector as? Range }
|
||||||
"mo" -> DayOfWeek.MONDAY
|
|
||||||
"tu" -> DayOfWeek.TUESDAY
|
|
||||||
"we" -> DayOfWeek.WEDNESDAY
|
|
||||||
"th" -> DayOfWeek.THURSDAY
|
|
||||||
"fr" -> DayOfWeek.FRIDAY
|
|
||||||
"sa" -> DayOfWeek.SATURDAY
|
|
||||||
"su" -> DayOfWeek.SUNDAY
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
var allDay = false
|
val applicableRules = rangeRules
|
||||||
var everyDay = false
|
.flatMapNotNull { range ->
|
||||||
|
with(range) {
|
||||||
|
when {
|
||||||
|
weekdays != null -> weekdays!!.flatMap {
|
||||||
|
when (it) {
|
||||||
|
is Weekday -> listOf(it to range)
|
||||||
|
is WeekdayRange -> (it.start.ordinal..it.end.ordinal).map { Weekday.entries[it] to range }
|
||||||
|
is SpecificWeekdays -> listOf(it.weekday to range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun parseGroup(group: List<String>) {
|
times.isNullOrEmpty().not() || months.isNullOrEmpty().not() ->
|
||||||
if (group.isEmpty())
|
Weekday.entries.map { it to range }
|
||||||
return
|
|
||||||
|
|
||||||
var times = group
|
else -> null
|
||||||
.filter { timeRegex.matches(it) }
|
|
||||||
.mapNotNull {
|
|
||||||
try {
|
|
||||||
val startTime =
|
|
||||||
LocalTime.parse(it.substringBefore('-'), DATE_TIME_FORMATTER)
|
|
||||||
val endTime =
|
|
||||||
LocalTime.parse(it.substringAfter('-'), DATE_TIME_FORMATTER)
|
|
||||||
|
|
||||||
var duration = Duration.between(startTime, endTime)
|
|
||||||
|
|
||||||
if (duration.isNegative || duration.isZero)
|
|
||||||
duration += Duration.ofDays(1)
|
|
||||||
|
|
||||||
startTime to duration
|
|
||||||
} catch (dtpe: DateTimeParseException) {
|
|
||||||
Log.e(
|
|
||||||
"OpeningTimeFromOverpassElement",
|
|
||||||
"Failed to parse opening time $it",
|
|
||||||
dtpe
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.toMultiMap().mapNotNull { (day, rules) ->
|
||||||
var days = group
|
rules.filterYears(localTime)
|
||||||
.filter { dayRangeRegex.matches(it) }
|
.filterMonths(localTime)
|
||||||
.flatMap {
|
.filterNthDays(localTime)
|
||||||
val dowStart = dayOfWeekFromString(it.substringBefore('-'))
|
.map { it.copy(weekdays = listOf(day)) }
|
||||||
?: return@flatMap emptyList()
|
.singleOrNull()
|
||||||
val dowEnd = dayOfWeekFromString(it.substringAfter('-'))
|
|
||||||
?: return@flatMap emptyList()
|
|
||||||
|
|
||||||
if (dowStart.ordinal <= dowEnd.ordinal)
|
|
||||||
daysOfWeek.subList(dowStart.ordinal, dowEnd.ordinal + 1)
|
|
||||||
else // "We-Mo"
|
|
||||||
daysOfWeek.subList(dowStart.ordinal, daysOfWeek.size)
|
|
||||||
.union(daysOfWeek.subList(0, dowEnd.ordinal + 1))
|
|
||||||
}.union(
|
|
||||||
group.filter { singleDayRegex.matches(it) }
|
|
||||||
.mapNotNull { dayOfWeekFromString(it) }
|
|
||||||
)
|
|
||||||
|
|
||||||
// if no time specified, treat as "all day"
|
|
||||||
if (times.isEmpty()) {
|
|
||||||
allDay = true
|
|
||||||
times = listOf(LocalTime.MIDNIGHT to Duration.ofDays(1))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no day specified, treat as "every day"
|
val hours = mutableListOf<OpeningHours>()
|
||||||
if (days.isEmpty()) {
|
|
||||||
if (group.any { it.equals("PH", ignoreCase = true) }) {
|
for (range in applicableRules) {
|
||||||
times = emptyList()
|
|
||||||
} else {
|
val localTimesWithDuration =
|
||||||
everyDay = true
|
range.times?.flatMap { it.toLocalTimeWithDuration() } ?: continue
|
||||||
days = daysOfWeek.toSet()
|
val daysOfWeek = range.weekdays
|
||||||
|
.ifNullOrEmpty { Weekday.entries.toList() }
|
||||||
|
.flatMap { it.toDaysOfWeek() }
|
||||||
|
|
||||||
|
hours += daysOfWeek.flatMap { dow ->
|
||||||
|
localTimesWithDuration.map {
|
||||||
|
val (start, dur) = it
|
||||||
|
OpeningHours(dow, start, dur)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
openingHours.addAll(days.flatMap { day ->
|
return OpeningSchedule.Hours(hours)
|
||||||
times.map { (start, duration) ->
|
}
|
||||||
OpeningHours(
|
|
||||||
dayOfWeek = day,
|
private fun List<Range>.filterYears(localTime: LocalDateTime): List<Range> = when {
|
||||||
startTime = start,
|
none { it.years.isNullOrEmpty().not() } -> this
|
||||||
duration = duration
|
else -> filter {
|
||||||
)
|
(it.years ?: return@filter false).any {
|
||||||
|
when (it) {
|
||||||
|
is Year -> it.year == localTime.year
|
||||||
|
is StartingAtYear -> it.start <= localTime.year
|
||||||
|
is YearRange -> localTime.year in it.start..it.end step (it.step ?: 1)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (blocks.isEmpty())
|
|
||||||
break
|
|
||||||
|
|
||||||
// assuming that there are blocks that only contain time
|
|
||||||
// treating them as "every day of the week"
|
|
||||||
if (blocks.size < 2) {
|
|
||||||
parseGroup(blocks)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
}.ifEmpty {
|
||||||
val nextTimeIndex =
|
filter {
|
||||||
blocks.indexOfFirst { timeRegex.matches(it) }
|
it.years.isNullOrEmpty()
|
||||||
|
|
||||||
// no time left, so probably no sensible information
|
|
||||||
// willingly skips "off" and "closed" as they are not useful
|
|
||||||
if (nextTimeIndex == -1)
|
|
||||||
break
|
|
||||||
|
|
||||||
// assuming next block to start with the first date coming after a time block
|
|
||||||
var nextGroupIndex =
|
|
||||||
blocks.subList(nextTimeIndex, blocks.size)
|
|
||||||
.indexOfFirst { !timeRegex.matches(it) }
|
|
||||||
|
|
||||||
// no day left, so we are done
|
|
||||||
if (nextGroupIndex == -1) {
|
|
||||||
parseGroup(blocks)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// convert index from sublist context
|
|
||||||
nextGroupIndex += nextTimeIndex
|
|
||||||
|
|
||||||
parseGroup(blocks.subList(0, nextGroupIndex))
|
|
||||||
blocks = blocks.subList(nextGroupIndex, blocks.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (allDay && everyDay) {
|
|
||||||
OpeningSchedule.TwentyFourSeven
|
|
||||||
} else {
|
|
||||||
OpeningSchedule.Hours(openingHours)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<Range>.filterMonths(localTime: LocalDateTime): List<Range> = when {
|
||||||
|
none { it.months.isNullOrEmpty().not() } -> this
|
||||||
|
else -> filter {
|
||||||
|
(it.months ?: return@filter false).any {
|
||||||
|
when (it) {
|
||||||
|
is MonthRange -> (it.year?.let { it == localTime.year } != false) && localTime.month.ordinal in it.start.ordinal..it.end.ordinal
|
||||||
|
|
||||||
|
is SingleMonth -> (it.year?.let { it == localTime.year } != false) && localTime.month.ordinal == it.month.ordinal
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.ifEmpty {
|
||||||
|
filter {
|
||||||
|
it.months.isNullOrEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Range>.filterNthDays(localTime: LocalDateTime): List<Range> = when {
|
||||||
|
none { it.weekdays?.any { it is SpecificWeekdays } == true } -> this
|
||||||
|
else -> localTime.getNthWeekdaysOfCurrentWeek().let { currentWeek ->
|
||||||
|
|
||||||
|
val specific = mapNotNull { range ->
|
||||||
|
(range.weekdays?.singleOrNull() as? SpecificWeekdays)?.let {
|
||||||
|
range to it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val unspecific = single { it.weekdays?.singleOrNull() !is SpecificWeekdays }
|
||||||
|
|
||||||
|
specific.firstOrNull { (_, specific) ->
|
||||||
|
currentWeek.any { (dow, nthFwd, nthBwd) ->
|
||||||
|
specific.weekday.ordinal == dow.ordinal && specific.nths.any {
|
||||||
|
when (it) {
|
||||||
|
is Nth -> it.nth == nthFwd
|
||||||
|
is NthRange -> nthFwd in it.start..it.end
|
||||||
|
is LastNth -> it.nth == nthBwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}?.let {
|
||||||
|
(rule, _) -> listOf(rule)
|
||||||
|
} ?: listOf(unspecific)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun WeekdaysSelector.toDaysOfWeek(): List<DayOfWeek> = when (this) {
|
||||||
|
is Weekday -> listOf(
|
||||||
|
when (this) {
|
||||||
|
Weekday.Monday -> DayOfWeek.MONDAY
|
||||||
|
Weekday.Tuesday -> DayOfWeek.TUESDAY
|
||||||
|
Weekday.Wednesday -> DayOfWeek.WEDNESDAY
|
||||||
|
Weekday.Thursday -> DayOfWeek.THURSDAY
|
||||||
|
Weekday.Friday -> DayOfWeek.FRIDAY
|
||||||
|
Weekday.Saturday -> DayOfWeek.SATURDAY
|
||||||
|
Weekday.Sunday -> DayOfWeek.SUNDAY
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
is WeekdayRange -> (start to end).map { it.toDaysOfWeek().single().value }
|
||||||
|
.into { start, end -> (start..end).map { DayOfWeek.of(it) } }
|
||||||
|
|
||||||
|
is SpecificWeekdays -> weekday.toDaysOfWeek()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun TimesSelector.toLocalTimeWithDuration(): List<Pair<LocalTime, Duration>> {
|
||||||
|
return when (this) {
|
||||||
|
is TimeSpan -> {
|
||||||
|
val start = start as? ClockTime ?: return emptyList()
|
||||||
|
val end = end as? ExtendedClockTime ?: return emptyList()
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
LocalTime.of(
|
||||||
|
start.hour,
|
||||||
|
start.minutes
|
||||||
|
) to Duration.ofMinutes((Math.floorMod(end.hour - start.hour, 24) * 60 + end.minutes - start.minutes).toLong())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDateTime.getNthWeekdaysOfCurrentWeek(): List<Triple<DayOfWeek, Int, Int>> {
|
||||||
|
val monday = with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||||
|
return (0 until 7).map { i ->
|
||||||
|
val (nth, nthLast) = monday.plusDays(i.toLong()).let { weekday ->
|
||||||
|
(
|
||||||
|
ChronoUnit.WEEKS.between(
|
||||||
|
with(TemporalAdjusters.firstInMonth(weekday.dayOfWeek)),
|
||||||
|
weekday
|
||||||
|
).toInt() + 1
|
||||||
|
) to (
|
||||||
|
ChronoUnit.WEEKS.between(
|
||||||
|
weekday,
|
||||||
|
with(TemporalAdjusters.lastInMonth(weekday.dayOfWeek))
|
||||||
|
).toInt() + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Triple(DayOfWeek.entries[i], nth, nthLast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
283
data/locations/src/test/kotlin/OpeningHoursTest.kt
Normal file
283
data/locations/src/test/kotlin/OpeningHoursTest.kt
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
import de.mm20.launcher2.locations.providers.openstreetmaps.parseOpeningSchedule
|
||||||
|
import de.mm20.launcher2.search.location.OpeningHours
|
||||||
|
import de.mm20.launcher2.search.location.OpeningSchedule
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.Month
|
||||||
|
|
||||||
|
class OpeningHoursTest {
|
||||||
|
|
||||||
|
private infix fun OpeningSchedule?.assertEqualTo(actual: OpeningSchedule?) = when (this) {
|
||||||
|
is OpeningSchedule.TwentyFourSeven -> Assert.assertTrue(actual is OpeningSchedule.TwentyFourSeven)
|
||||||
|
is OpeningSchedule.Hours -> {
|
||||||
|
actual as OpeningSchedule.Hours
|
||||||
|
Assert.assertEquals(openingHours.size, actual.openingHours.size)
|
||||||
|
val diff = openingHours.toSet() - actual.openingHours.toSet()
|
||||||
|
Assert.assertTrue("Set difference not empty: $diff", diff.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> Assert.assertNull(actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleAt(
|
||||||
|
osm: String,
|
||||||
|
year: Int = 2020,
|
||||||
|
month: Month = Month.JUNE,
|
||||||
|
dayOfMonth: Int = 17,
|
||||||
|
hour: Int = 9,
|
||||||
|
minute: Int = 44
|
||||||
|
): OpeningSchedule? =
|
||||||
|
parseOpeningSchedule(osm, LocalDateTime.of(year, month, dayOfMonth, hour, minute))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test247() = Assert.assertSame(
|
||||||
|
OpeningSchedule.TwentyFourSeven,
|
||||||
|
parseOpeningSchedule("24/7")
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEveryDaySame() = OpeningSchedule.Hours(
|
||||||
|
DayOfWeek.entries.map {
|
||||||
|
OpeningHours(
|
||||||
|
it, LocalTime.of(8, 0), Duration.ofHours(11)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) assertEqualTo parseOpeningSchedule(
|
||||||
|
"08:00-19:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDayOfWeek() = OpeningSchedule.Hours(
|
||||||
|
listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.WEDNESDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.THURSDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.FRIDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.SATURDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
)
|
||||||
|
) assertEqualTo parseOpeningSchedule(
|
||||||
|
"Mo 17:00-22:00; We-Sa 17:00-22:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMultipleRanges() = OpeningSchedule.Hours(
|
||||||
|
listOf(
|
||||||
|
OpeningHours(DayOfWeek.TUESDAY, LocalTime.of(11, 0), Duration.ofHours(4)),
|
||||||
|
OpeningHours(DayOfWeek.WEDNESDAY, LocalTime.of(11, 0), Duration.ofHours(4)),
|
||||||
|
OpeningHours(DayOfWeek.THURSDAY, LocalTime.of(11, 0), Duration.ofHours(4)),
|
||||||
|
OpeningHours(DayOfWeek.FRIDAY, LocalTime.of(11, 0), Duration.ofHours(4)),
|
||||||
|
OpeningHours(DayOfWeek.TUESDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.WEDNESDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.THURSDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.FRIDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(DayOfWeek.SATURDAY, LocalTime.of(17, 0), Duration.ofHours(5)),
|
||||||
|
OpeningHours(
|
||||||
|
DayOfWeek.SUNDAY,
|
||||||
|
LocalTime.of(11, 30),
|
||||||
|
Duration.ofHours(10) + Duration.ofMinutes(30)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) assertEqualTo parseOpeningSchedule(
|
||||||
|
"Tu-Fr 11:00-15:00, 17:00-22:00; Sa 17:00-22:00; Su 11:30-22:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testComment() = OpeningSchedule.Hours(
|
||||||
|
listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(11, 0), Duration.ofHours(7)),
|
||||||
|
OpeningHours(DayOfWeek.TUESDAY, LocalTime.of(11, 0), Duration.ofHours(7)),
|
||||||
|
OpeningHours(DayOfWeek.WEDNESDAY, LocalTime.of(11, 0), Duration.ofHours(7)),
|
||||||
|
OpeningHours(DayOfWeek.THURSDAY, LocalTime.of(11, 0), Duration.ofHours(7)),
|
||||||
|
OpeningHours(DayOfWeek.FRIDAY, LocalTime.of(11, 0), Duration.ofHours(7)),
|
||||||
|
OpeningHours(DayOfWeek.SATURDAY, LocalTime.of(11, 0), Duration.ofHours(7)),
|
||||||
|
OpeningHours(DayOfWeek.SUNDAY, LocalTime.of(13, 0), Duration.ofHours(5)),
|
||||||
|
)
|
||||||
|
) assertEqualTo parseOpeningSchedule(
|
||||||
|
"Mo-Sa 11:00-18:00; Su 13:00-18:00; \"Holiday until 11.02.2022\""
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMonthException() {
|
||||||
|
val expectedNoDecember = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8, 0), Duration.ofHours(8))
|
||||||
|
)
|
||||||
|
val expectedDecember = emptyList<OpeningHours>()
|
||||||
|
|
||||||
|
for (month in Month.entries) {
|
||||||
|
OpeningSchedule.Hours(
|
||||||
|
if (month == Month.DECEMBER)
|
||||||
|
expectedDecember
|
||||||
|
else
|
||||||
|
expectedNoDecember
|
||||||
|
) assertEqualTo scheduleAt(
|
||||||
|
"Mo 08:00-16:00; Dec off",
|
||||||
|
month = month
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMonthWeekdayException() {
|
||||||
|
val expectedDecember =
|
||||||
|
listOf(OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8, 0), Duration.ofHours(4)))
|
||||||
|
val expectedNoDecember =
|
||||||
|
listOf(OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8, 0), Duration.ofHours(8)))
|
||||||
|
|
||||||
|
for (month in Month.entries) {
|
||||||
|
OpeningSchedule.Hours(
|
||||||
|
if (month == Month.DECEMBER)
|
||||||
|
expectedDecember
|
||||||
|
else
|
||||||
|
expectedNoDecember
|
||||||
|
) assertEqualTo scheduleAt(
|
||||||
|
"Mo 08:00-16:00; Dec Mo 08:00-12:00",
|
||||||
|
month = month
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMonthSpanException() {
|
||||||
|
val expectedInRange = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8, 0), Duration.ofHours(4))
|
||||||
|
)
|
||||||
|
val expectedOutOfRange = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8, 0), Duration.ofHours(8))
|
||||||
|
)
|
||||||
|
|
||||||
|
for (month in Month.entries) {
|
||||||
|
OpeningSchedule.Hours(
|
||||||
|
if (month in Month.JANUARY..Month.MARCH)
|
||||||
|
expectedInRange
|
||||||
|
else
|
||||||
|
expectedOutOfRange
|
||||||
|
) assertEqualTo scheduleAt(
|
||||||
|
"Mo 08:00-16:00; Jan-Mar Mo 08:00-12:00",
|
||||||
|
month = month
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSundayOff() = OpeningSchedule.Hours(
|
||||||
|
listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(11, 0), Duration.ofHours(10)),
|
||||||
|
OpeningHours(DayOfWeek.TUESDAY, LocalTime.of(11, 0), Duration.ofHours(10)),
|
||||||
|
OpeningHours(DayOfWeek.WEDNESDAY, LocalTime.of(11, 0), Duration.ofHours(10)),
|
||||||
|
OpeningHours(DayOfWeek.THURSDAY, LocalTime.of(11, 0), Duration.ofHours(10)),
|
||||||
|
OpeningHours(DayOfWeek.FRIDAY, LocalTime.of(11, 0), Duration.ofHours(10)),
|
||||||
|
OpeningHours(DayOfWeek.SATURDAY, LocalTime.of(11, 0), Duration.ofHours(10)),
|
||||||
|
)
|
||||||
|
) assertEqualTo scheduleAt(
|
||||||
|
"Mo-Sa 11:00-21:00; PH,Su off"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNthWeekday() {
|
||||||
|
val usualWeek = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(8))
|
||||||
|
)
|
||||||
|
val specialMondayWeek = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(4))
|
||||||
|
)
|
||||||
|
|
||||||
|
for (week in 1..4) {
|
||||||
|
OpeningSchedule.Hours(
|
||||||
|
if (week == 2)
|
||||||
|
specialMondayWeek
|
||||||
|
else
|
||||||
|
usualWeek
|
||||||
|
) assertEqualTo scheduleAt(
|
||||||
|
"Mo 08:00-16:00; Mo[2] 08:00-12:00",
|
||||||
|
dayOfMonth = 1 + (week - 1) * 7
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLastNthWeekday() {
|
||||||
|
val usualWeek = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(8))
|
||||||
|
)
|
||||||
|
val specialMondayWeek = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(4))
|
||||||
|
)
|
||||||
|
|
||||||
|
for (week in 1..5) {
|
||||||
|
OpeningSchedule.Hours(
|
||||||
|
if (week == 5)
|
||||||
|
specialMondayWeek
|
||||||
|
else
|
||||||
|
usualWeek
|
||||||
|
) assertEqualTo scheduleAt(
|
||||||
|
"Mo 08:00-16:00; Mo[-1] 08:00-12:00",
|
||||||
|
dayOfMonth = 1 + (week - 1) * 7
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMondayOnDecember() {
|
||||||
|
val december = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(4)),
|
||||||
|
OpeningHours(DayOfWeek.FRIDAY, LocalTime.of(8,0), Duration.ofHours(2))
|
||||||
|
)
|
||||||
|
val notDecember = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(8)),
|
||||||
|
OpeningHours(DayOfWeek.FRIDAY, LocalTime.of(8,0), Duration.ofHours(2))
|
||||||
|
)
|
||||||
|
for (month in Month.entries) {
|
||||||
|
OpeningSchedule.Hours(
|
||||||
|
if (month == Month.DECEMBER)
|
||||||
|
december
|
||||||
|
else
|
||||||
|
notDecember
|
||||||
|
) assertEqualTo scheduleAt(
|
||||||
|
"Mo 08:00-16:00; Dec Mo 08:00-12:00; Fr 08:00-10:00",
|
||||||
|
month = month
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAllTogether() {
|
||||||
|
val dec = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(8))
|
||||||
|
) + listOf(DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.SATURDAY).map {
|
||||||
|
OpeningHours(it, LocalTime.of(17,0), Duration.ofHours(8))
|
||||||
|
}
|
||||||
|
val janMar = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(6,0), Duration.ofHours(6))
|
||||||
|
) + listOf(DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY).map {
|
||||||
|
OpeningHours(it, LocalTime.of(17,0), Duration.ofHours(8))
|
||||||
|
}
|
||||||
|
val aug = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(0,30), Duration.ofMinutes(45))
|
||||||
|
) + listOf(DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY).map {
|
||||||
|
OpeningHours(it, LocalTime.of(17,0), Duration.ofHours(8))
|
||||||
|
}
|
||||||
|
val elze = listOf(
|
||||||
|
OpeningHours(DayOfWeek.MONDAY, LocalTime.of(8,0), Duration.ofHours(8))
|
||||||
|
) + listOf(DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY).map {
|
||||||
|
OpeningHours(it, LocalTime.of(17,0), Duration.ofHours(8))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (month in Month.entries) {
|
||||||
|
OpeningSchedule.Hours(when (month) {
|
||||||
|
in Month.JANUARY..Month.MARCH -> janMar
|
||||||
|
Month.AUGUST -> aug
|
||||||
|
Month.DECEMBER -> dec
|
||||||
|
else -> elze
|
||||||
|
}) assertEqualTo scheduleAt(
|
||||||
|
"Mo 08:00-16:00; We-Sa 17:00-01:00; Jan-Mar Mo 06:00-12:00; Dec Fr off; Aug Mo 00:30-01:15; PH,Su off; \"Holiday until 06.09.2420\"",
|
||||||
|
month = month
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,6 +42,8 @@ junit = "4.13.2"
|
|||||||
junitVersion = "1.1.5"
|
junitVersion = "1.1.5"
|
||||||
espressoCore = "3.5.1"
|
espressoCore = "3.5.1"
|
||||||
|
|
||||||
|
osmOpeningHours = "0.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
mustache-compiler = { module = "com.github.spullara.mustache.java:compiler", version.ref = "mustache" }
|
mustache-compiler = { module = "com.github.spullara.mustache.java:compiler", version.ref = "mustache" }
|
||||||
gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" }
|
gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" }
|
||||||
@ -138,6 +140,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
|
|||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
|
||||||
|
osmopeninghours = { group = "de.westnordost", name = "osm-opening-hours", version.ref = "osmOpeningHours" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
kotlin = ["kotlin-stdlib", "kotlinx-coroutines-core", "kotlinx-coroutines-android", "kotlinx-collections-immutable", "kotlinx-serialization-json"]
|
kotlin = ["kotlin-stdlib", "kotlinx-coroutines-core", "kotlinx-coroutines-android", "kotlinx-collections-immutable", "kotlinx-serialization-json"]
|
||||||
androidx-lifecycle = ["androidx-lifecycle-viewmodel", "androidx-lifecycle-common", "androidx-lifecycle-runtime", "androidx-lifecycle-viewmodelcompose", "androidx-lifecycle-runtimecompose"]
|
androidx-lifecycle = ["androidx-lifecycle-viewmodel", "androidx-lifecycle-common", "androidx-lifecycle-runtime", "androidx-lifecycle-viewmodelcompose", "androidx-lifecycle-runtimecompose"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user