From 16637265fb28876090e1e53e1b982d03fef3ac44 Mon Sep 17 00:00:00 2001 From: Christoph <47949835+Sir-Photch@users.noreply.github.com> Date: Sun, 20 Oct 2024 11:47:16 +0200 Subject: [PATCH] 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 --- .../launcher2/licenses/OpenSourceLicenses.kt | 8 + .../launcher2/search/OpeningScheduleTest.kt | 3 +- .../java/de/mm20/launcher2/ktx/Iterable.kt | 18 + .../main/java/de/mm20/launcher2/ktx/List.kt | 6 +- .../main/java/de/mm20/launcher2/ktx/Pair.kt | 5 + data/locations/build.gradle.kts | 3 + .../providers/openstreetmaps/OsmLocation.kt | 355 +++++++++--------- .../src/test/kotlin/OpeningHoursTest.kt | 283 ++++++++++++++ gradle/libs.versions.toml | 4 + 9 files changed, 513 insertions(+), 172 deletions(-) create mode 100644 core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt create mode 100644 core/ktx/src/main/java/de/mm20/launcher2/ktx/Pair.kt create mode 100644 data/locations/src/test/kotlin/OpeningHoursTest.kt diff --git a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt index 4aa22861..38006787 100644 --- a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt +++ b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt @@ -228,4 +228,12 @@ val OpenSourceLicenses = arrayOf( url = "https://github.com/aallam/string-similarity-kotlin", 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", + ) ) \ No newline at end of file diff --git a/core/base/src/test/java/de/mm20/launcher2/search/OpeningScheduleTest.kt b/core/base/src/test/java/de/mm20/launcher2/search/OpeningScheduleTest.kt index 3ddf4da4..b3f51ba6 100644 --- a/core/base/src/test/java/de/mm20/launcher2/search/OpeningScheduleTest.kt +++ b/core/base/src/test/java/de/mm20/launcher2/search/OpeningScheduleTest.kt @@ -18,8 +18,7 @@ class OpeningScheduleTest(val date: LocalDateTime, val expected: Boolean) { @Test fun isOpen() { - val openingSchedule = OpeningSchedule( - isTwentyFourSeven = false, + val openingSchedule = OpeningSchedule.Hours( /** * Monday: 18:00 - Tue. 01:00 * Tuesday: 10:00 - 00:00 diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt new file mode 100644 index 00000000..8e878765 --- /dev/null +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt @@ -0,0 +1,18 @@ +package de.mm20.launcher2.ktx + +fun Iterable.flatMapNotNull(transform: (T) -> Iterable?) : List { + val destination = mutableListOf() + for (it in this) { + val transformed = transform(it) ?: continue + destination.addAll(transformed) + } + return destination +} + +fun Iterable>.toMultiMap() : Map> { + val destination = mutableMapOf>() + for ((k, v) in this) { + destination.getOrPut(k) { mutableListOf() } += v + } + return destination +} diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt index 9b8e0080..f305ba7e 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt @@ -10,4 +10,8 @@ fun List.randomElement(): T { fun List.randomElementOrNull(): T? { if (isEmpty()) return null return get(Random().nextInt(size)) -} \ No newline at end of file +} + +fun List?.ifNullOrEmpty(block: () -> List): List { + return if (this.isNullOrEmpty()) block() else this +} diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Pair.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Pair.kt new file mode 100644 index 00000000..2609f435 --- /dev/null +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Pair.kt @@ -0,0 +1,5 @@ +package de.mm20.launcher2.ktx + +fun Pair.map(transform: (A) -> B): Pair = transform(first) to transform(second) + +fun Pair.into(transform: (A, B) -> C): C = transform(first, second) diff --git a/data/locations/build.gradle.kts b/data/locations/build.gradle.kts index 9f475ebc..6ef81d72 100644 --- a/data/locations/build.gradle.kts +++ b/data/locations/build.gradle.kts @@ -50,6 +50,8 @@ dependencies { implementation(libs.androidx.appcompat) + implementation(libs.osmopeninghours) + implementation(project(":core:preferences")) implementation(project(":core:base")) implementation(project(":core:ktx")) @@ -57,4 +59,5 @@ dependencies { implementation(project(":core:crashreporter")) implementation(project(":core:devicepose")) implementation(project(":libs:address-formatter")) + testImplementation(libs.junit) } \ No newline at end of file diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt index 837514c2..c1aa85c1 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt @@ -1,7 +1,11 @@ package de.mm20.launcher2.locations.providers.openstreetmaps 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.openstreetmaps.R 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.OpeningHours 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.put import org.woheller69.AndroidAddressFormatter.OsmAddressFormatter import java.time.DayOfWeek import java.time.Duration +import java.time.LocalDateTime import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException -import java.time.format.ResolverStyle +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalAdjusters import java.util.Locale import kotlin.math.min @@ -281,6 +303,7 @@ private fun Map.categorize(context: Context): Pair R.string.poi_category_golf to LocationIcon.Golf "park" -> R.string.poi_category_park to LocationIcon.Park else -> null @@ -308,187 +331,181 @@ private fun Map.categorize(context: Context): Pair().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() - - // 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") + if (parsed.rules.singleOrNull()?.selector is TwentyFourSeven) return OpeningSchedule.TwentyFourSeven - fun dayOfWeekFromString(it: String): DayOfWeek? = when (it.lowercase()) { - "mo" -> DayOfWeek.MONDAY - "tu" -> DayOfWeek.TUESDAY - "we" -> DayOfWeek.WEDNESDAY - "th" -> DayOfWeek.THURSDAY - "fr" -> DayOfWeek.FRIDAY - "sa" -> DayOfWeek.SATURDAY - "su" -> DayOfWeek.SUNDAY - else -> null - } + val rangeRules = parsed.rules.mapNotNull { it.selector as? Range } - var allDay = false - var everyDay = false + val applicableRules = rangeRules + .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) { - if (group.isEmpty()) - return + times.isNullOrEmpty().not() || months.isNullOrEmpty().not() -> + Weekday.entries.map { it to range } - var times = group - .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 + else -> null } } - - var days = group - .filter { dayRangeRegex.matches(it) } - .flatMap { - val dowStart = dayOfWeekFromString(it.substringBefore('-')) - ?: return@flatMap emptyList() - 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)) + }.toMultiMap().mapNotNull { (day, rules) -> + rules.filterYears(localTime) + .filterMonths(localTime) + .filterNthDays(localTime) + .map { it.copy(weekdays = listOf(day)) } + .singleOrNull() } - // if no day specified, treat as "every day" - if (days.isEmpty()) { - if (group.any { it.equals("PH", ignoreCase = true) }) { - times = emptyList() - } else { - everyDay = true - days = daysOfWeek.toSet() + val hours = mutableListOf() + + for (range in applicableRules) { + + val localTimesWithDuration = + range.times?.flatMap { it.toLocalTimeWithDuration() } ?: continue + 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 -> - times.map { (start, duration) -> - OpeningHours( - dayOfWeek = day, - startTime = start, - duration = duration - ) + return OpeningSchedule.Hours(hours) +} + +private fun List.filterYears(localTime: LocalDateTime): List = when { + none { it.years.isNullOrEmpty().not() } -> this + 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 } - - val nextTimeIndex = - blocks.indexOfFirst { timeRegex.matches(it) } - - // 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 + }.ifEmpty { + filter { + it.years.isNullOrEmpty() } - - // 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.filterMonths(localTime: LocalDateTime): List = 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.filterNthDays(localTime: LocalDateTime): List = 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 = 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> { + 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> { + 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) + } +} diff --git a/data/locations/src/test/kotlin/OpeningHoursTest.kt b/data/locations/src/test/kotlin/OpeningHoursTest.kt new file mode 100644 index 00000000..59942c8e --- /dev/null +++ b/data/locations/src/test/kotlin/OpeningHoursTest.kt @@ -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() + + 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 + ) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 173295bf..da013608 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,8 @@ junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" +osmOpeningHours = "0.1.0" + [libraries] 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" } @@ -138,6 +140,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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] 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"]