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:
Christoph 2024-10-20 11:47:16 +02:00 committed by GitHub
parent 7d490f2179
commit 16637265fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 513 additions and 172 deletions

View File

@ -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",
)
) )

View File

@ -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

View 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
}

View File

@ -10,4 +10,8 @@ fun <T> List<T>.randomElement(): T {
fun <T> List<T>.randomElementOrNull(): T? { 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
}

View 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)

View File

@ -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)
} }

View File

@ -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)
}
}

View 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
)
}
}
}

View File

@ -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"]