aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt
blob: c8f8fa3abf06bde22fd797e076085b3e36eb4abf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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"))
    }
}