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 = emptyList(), attendees: List = 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 { val out = ArrayList() 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): List { val out = ArrayList() 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): List> { val out = ArrayList>() var cur: MutableList? = 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.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")) } }