diff options
Diffstat (limited to 'packages/meshbay-android/app/src/test')
| -rw-r--r-- | packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt | 117 |
1 files changed, 117 insertions, 0 deletions
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt new file mode 100644 index 0000000..c8f8fa3 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt @@ -0,0 +1,117 @@ +package org.meshbay.client + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.phonesync.Attendee +import org.meshbay.client.phonesync.CalEvent +import org.meshbay.client.phonesync.Ics + +class IcsTest { + // 2026-10-09 12:00:00 UTC + private val oct9 = 1791547200000L + private val hour = 3600_000L + + private fun event(id: Long, title: String = "Lunch", start: Long = oct9, end: Long? = oct9 + hour, + duration: String? = null, allDay: Boolean = false, zone: String? = "Europe/Paris", + rrule: String? = null, exdate: String? = null, originalId: Long? = null, + originalTime: Long? = null, uid: String? = null, status: Int? = 1, + reminders: List<Int> = emptyList(), attendees: List<Attendee> = emptyList(), + description: String? = null) = + CalEvent(id, "Personal", uid, title, description, null, start, end, duration, allDay, zone, + rrule, null, exdate, originalId, originalTime, false, status, reminders, null, attendees) + + private fun lines(vararg e: CalEvent): List<String> { + val out = ArrayList<String>() + Ics.write(e.toList(), oct9) { out += it } + return out + } + + /** RFC 5545 unfolding: a line starting with a space continues the one before. */ + private fun unfold(l: List<String>): List<String> { + val out = ArrayList<String>() + for (x in l) if (x.startsWith(" ")) out[out.size - 1] = out.last() + x.substring(1) else out += x + return out + } + + private fun events(l: List<String>): List<List<String>> { + val out = ArrayList<List<String>>() + var cur: MutableList<String>? = null + for (x in unfold(l)) when (x) { + "BEGIN:VEVENT" -> cur = ArrayList() + "END:VEVENT" -> { out += cur!!; cur = null } + else -> cur?.add(x) + } + return out + } + + private fun List<String>.prop(name: String) = firstOrNull { it.startsWith("$name:") || it.startsWith("$name;") } + + @Test fun `a calendar wraps the events, with what an importer requires`() { + val l = lines(event(1)) + assertEquals("BEGIN:VCALENDAR", l.first()) + assertEquals("END:VCALENDAR", l.last()) + assertTrue("VERSION:2.0" in l) + val e = events(l).single() + assertEquals("UID:android-1@meshbay", e.prop("UID")) + assertEquals("DTSTAMP:20261009T120000Z", e.prop("DTSTAMP")) + } + + @Test fun `a timed event keeps its zone, so a recurrence follows summer time`() { + val e = events(lines(event(1)))[0] + assertEquals("DTSTART;TZID=Europe/Paris:20261009T140000", e.prop("DTSTART")) + assertEquals("DTEND;TZID=Europe/Paris:20261009T150000", e.prop("DTEND")) + val utc = events(lines(event(2, zone = null)))[0] + assertEquals("DTSTART:20261009T120000Z", utc.prop("DTSTART")) + val unknown = events(lines(event(3, zone = "Not/AZone")))[0] + assertEquals("DTSTART:20261009T120000Z", unknown.prop("DTSTART")) + } + + @Test fun `an all-day event is a date`() { + val day = 1791504000000L // 2026-10-09 00:00 UTC + val e = events(lines(event(1, start = day, end = day + 24 * hour, allDay = true)))[0] + assertEquals("DTSTART;VALUE=DATE:20261009", e.prop("DTSTART")) + assertEquals("DTEND;VALUE=DATE:20261010", e.prop("DTEND")) + } + + @Test fun `a recurring event keeps its rule, and Android's duration is made valid`() { + val e = events(lines(event(1, end = null, duration = "P3600S", rrule = "FREQ=WEEKLY;BYDAY=FR", + exdate = "Europe/Paris;20261016T140000")))[0] + assertEquals("RRULE:FREQ=WEEKLY;BYDAY=FR", e.prop("RRULE")) + assertEquals("DURATION:PT3600S", e.prop("DURATION")) + assertEquals("EXDATE;TZID=Europe/Paris:20261016T140000", e.prop("EXDATE")) + assertNull(e.prop("DTEND")) + assertEquals("P1D", Ics.duration("P1D")) + assertEquals("PT1H30M", Ics.duration("PT1H30M")) + assertNull(Ics.duration("nonsense")) + } + + @Test fun `a changed instance carries the series' UID and says which instance`() { + val master = event(1, uid = "abc@example.org", end = null, duration = "P3600S", rrule = "FREQ=DAILY") + val moved = event(2, start = oct9 + 26 * hour, end = oct9 + 27 * hour, originalId = 1, + originalTime = oct9 + 24 * hour) + val e = events(lines(master, moved)) + assertEquals("UID:abc@example.org", e[0].prop("UID")) + assertEquals("UID:abc@example.org", e[1].prop("UID")) + assertEquals("RECURRENCE-ID;TZID=Europe/Paris:20261010T140000", e[1].prop("RECURRENCE-ID")) + } + + @Test fun `text is escaped and long lines folded without splitting a character`() { + val desc = "a;b,c\\d\nsecond line " + "é".repeat(60) + " 🎉".repeat(20) + val l = lines(event(1, description = desc)) + assertTrue(l.all { it.toByteArray(Charsets.UTF_8).size <= 75 }) + val e = events(l)[0] + assertEquals("DESCRIPTION:" + "a\\;b\\,c\\\\d\\nsecond line " + "é".repeat(60) + " 🎉".repeat(20), + e.prop("DESCRIPTION")) + } + + @Test fun `reminders, attendees and a cancelled status come along`() { + val e = events(lines(event(1, status = 2, reminders = listOf(10, 10, 60), + attendees = listOf(Attendee("ada@example.org", "Ada, L.")))))[0] + assertEquals("STATUS:CANCELLED", e.prop("STATUS")) + assertEquals(listOf("TRIGGER:-PT10M", "TRIGGER:-PT60M"), e.filter { it.startsWith("TRIGGER") }) + assertEquals("ATTENDEE;CN=\"Ada, L.\":mailto:ada@example.org", e.prop("ATTENDEE")) + assertEquals("X-MESHBAY-CALENDAR:Personal", e.prop("X-MESHBAY-CALENDAR")) + } +} |