Bug 1492436 - Delete a recurring item if the last occurrence is deleted; r=philipp
authorMakeMyDay <makemyday@gmx-topmail.de>
Wed, 19 Sep 2018 14:17:49 +0200
changeset 34097 aa9c13717f77c473dcbb7f10e6af6e5f6c51a2ff
parent 34096 ea538ce655fb3edfcd919a70c15822fda774299c
child 34098 568b7141662f2bd752fa11939c77bcb0149a8c9a
push id389
push userclokep@gmail.com
push dateMon, 18 Mar 2019 19:01:53 +0000
reviewersphilipp
bugs1492436
Bug 1492436 - Delete a recurring item if the last occurrence is deleted; r=philipp
calendar/base/content/calendar-views.js
calendar/base/modules/calRecurrenceUtils.jsm
calendar/lightning/content/lightning-item-iframe.js
calendar/test/unit/test_recurrence_utils.js
calendar/test/unit/xpcshell-shared.ini
--- a/calendar/base/content/calendar-views.js
+++ b/calendar/base/content/calendar-views.js
@@ -5,16 +5,17 @@
 /* exported switchToView, getSelectedDay, scheduleMidnightUpdate,
  *          updateStyleSheetForViews, observeViewDaySelect, toggleOrientation,
  *          toggleWorkdaysOnly, toggleTasksInView, toggleShowCompletedInView,
  *          goToDate, getLastCalendarView, deleteSelectedEvents,
  *          editSelectedEvents, selectAllEvents
  */
 
 var { cal } = ChromeUtils.import("resource://calendar/modules/calUtils.jsm", null);
+const { countOccurrences } = ChromeUtils.import("resource://calendar/modules/calRecurrenceUtils.jsm", null);
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/Preferences.jsm");
 
 /**
  * Controller for the views
  * @see calIcalendarViewController
  */
 var calendarViewController = {
@@ -110,33 +111,55 @@ var calendarViewController = {
         // Make sure we are modifying a copy of aOccurrences, otherwise we will
         // run into race conditions when the view's doDeleteItem removes the
         // array elements while we are iterating through them. While we are at
         // it, filter out any items that have readonly calendars, so that
         // checking for one total item below also works out if all but one item
         // are readonly.
         let occurrences = aOccurrences.filter(item => cal.acl.isCalendarWritable(item.calendar));
 
+        // we check how many occurrences the parent item has
+        let parents = new Map();
+        for (let occ of occurrences) {
+            if (!parents.has(occ.id)) {
+                parents.set(occ.id, countOccurrences(occ));
+            }
+        }
+
+        let promptUser = !aDoNotConfirm;
+        let previousResponse = 0;
         for (let itemToDelete of occurrences) {
-            if (aUseParentItems) {
+            if (parents.get(itemToDelete.id) == -1) {
+                // we have scheduled the master item for deletion in a previous
+                // loop already
+                continue;
+            }
+            if (aUseParentItems ||
+                parents.get(itemToDelete.id) == 1 ||
+                previousResponse == 3) {
                 // Usually happens when ctrl-click is used. In that case we
                 // don't need to ask the user if he wants to delete an
                 // occurrence or not.
+                // if an occurrence is the only one of a series or the user
+                // decided so before, we delete the series, too.
                 itemToDelete = itemToDelete.parentItem;
-            } else if (!aDoNotConfirm && occurrences.length == 1) {
-                // Only give the user the selection if only one occurrence is
-                // selected. Otherwise he will get a dialog for each occurrence
-                // he deletes.
+                parents.set(itemToDelete.id, -1);
+            } else if (promptUser) {
                 let [targetItem, , response] = promptOccurrenceModification(itemToDelete, false, "delete");
                 if (!response) {
                     // The user canceled the dialog, bail out
                     break;
                 }
+                itemToDelete = targetItem;
 
-                itemToDelete = targetItem;
+                // if we have multiple items and the user decided already for one
+                // item whether to delete the occurrence or the entire series,
+                // we apply that decission also to subsequent items
+                previoiusResponse = response;
+                promptUser = false;
             }
 
             // Now some dirty work: Make sure more than one occurrence can be
             // deleted by saving the recurring items and removing occurrences as
             // they come in. If this is not an occurrence, we can go ahead and
             // delete the whole item.
             if (itemToDelete.parentItem.hashId == itemToDelete.hashId) {
                 doTransaction("delete",
--- a/calendar/base/modules/calRecurrenceUtils.jsm
+++ b/calendar/base/modules/calRecurrenceUtils.jsm
@@ -1,18 +1,23 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-/* exported recurrenceRule2String, splitRecurrenceRules, checkRecurrenceRule */
+/* exported recurrenceRule2String, splitRecurrenceRules, checkRecurrenceRule
+ *          countOccurrences
+ */
 
 ChromeUtils.import("resource://gre/modules/PluralForm.jsm");
 const { cal } = ChromeUtils.import("resource://calendar/modules/calUtils.jsm", null);
 
-this.EXPORTED_SYMBOLS = ["recurrenceRule2String", "splitRecurrenceRules", "checkRecurrenceRule"];
+this.EXPORTED_SYMBOLS = [
+    "recurrenceRule2String", "splitRecurrenceRules", "checkRecurrenceRule",
+    "countOccurrences"
+];
 
 /**
  * This function takes the recurrence info passed as argument and creates a
  * literal string representing the repeat pattern in natural language.
  *
  * @param recurrenceInfo    An item's recurrence info to parse.
  * @param startDate         The start date to base rules on.
  * @param endDate           The end date to base rules on.
@@ -401,8 +406,77 @@ function checkRecurrenceRule(aRule, aArr
     for (let comp of aArray) {
         let ruleComp = aRule.getComponent(comp, {});
         if (ruleComp && ruleComp.length > 0) {
             return true;
         }
     }
     return false;
 }
+
+/**
+ * Counts the occurrences of the parent item if any of a provided item
+ *
+ * @param  {(calIEvent|calIToDo)}  aItem  item to count for
+ * @returns {(number|null)}               number of occurrences or null if the
+ *                                          passed item's parent item isn't a
+ *                                          recurring item or its recurrence is
+ *                                          infinite
+ */
+function countOccurrences(aItem) {
+    let occCounter = null;
+    let recInfo = aItem.parentItem.recurrenceInfo;
+    if (recInfo &&
+        recInfo.isFinite) {
+        occCounter = 0;
+        let excCounter = 0;
+        let byCount = false;
+        let ritems = recInfo.getRecurrenceItems({});
+        for (let ritem of ritems) {
+            if (ritem instanceof Ci.calIRecurrenceRule) {
+                if (ritem.isByCount) {
+                    occCounter = occCounter + ritem.count;
+                    byCount = true;
+                } else {
+                    // the rule is limited by as an until date
+                    let from = aItem.parentItem.startDate.clone();
+                    let until = aItem.parentItem.endDate.clone();
+                    if (until.compare(ritem.untilDate) == -1) {
+                        until = ritem.untilDate.clone();
+                    }
+
+                    let exceptionIds = recInfo.getExceptionIds({});
+                    for (let exceptionId of exceptionIds) {
+                        let recur = recInfo.getExceptionFor(exceptionId);
+                        if (from.compare(recur.startDate) == 1) {
+                            from = recur.startDate.clone();
+                        }
+                        if (until.compare(recur.endDate) == -1) {
+                            until = recur.endDate.clone();
+                        }
+                    }
+
+                    // we add an extra day at beginning and end, so we don't
+                    // neeed to take care of any timezone conversion
+                    from.addDuration(cal.createDuration("-P1D"));
+                    until.addDuration(cal.createDuration("P1D"));
+
+                    let occurrences = recInfo.getOccurrences(from, until, 0, {});
+                    occCounter = occCounter + occurrences.length;
+                }
+            } else if (ritem instanceof Ci.calIRecurrenceDate) {
+                if (ritem.isNegative) {
+                    // this is an exdate
+                    excCounter++;
+                } else {
+                    // this is an (additional) rdate
+                    occCounter++;
+                }
+            }
+        }
+
+        if (byCount) {
+            // for a rrule by count, we still need to substract exceptions if any
+            occCounter = occCounter - excCounter;
+        }
+    }
+    return occCounter;
+}
--- a/calendar/lightning/content/lightning-item-iframe.js
+++ b/calendar/lightning/content/lightning-item-iframe.js
@@ -14,17 +14,18 @@
  *          applyValues
  */
 
 var { cal } = ChromeUtils.import("resource://calendar/modules/calUtils.jsm", null);
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 const {
     recurrenceRule2String,
     splitRecurrenceRules,
-    checkRecurrenceRule
+    checkRecurrenceRule,
+    countOccurrences
 } = ChromeUtils.import("resource://calendar/modules/calRecurrenceUtils.jsm", null);
 ChromeUtils.import("resource://gre/modules/PluralForm.jsm");
 ChromeUtils.import("resource://gre/modules/Preferences.jsm");
 
 try {
     ChromeUtils.import("resource:///modules/cloudFileAccounts.js");
 } catch (e) {
     // This will fail on Seamonkey, but thats ok since the pref for cloudfiles
@@ -3220,21 +3221,29 @@ function onCommandDeleteItem() {
                     }
                 }
             }
         };
 
         eventDialogCalendarObserver.cancel();
         if (window.calendarItem.parentItem.recurrenceInfo && window.calendarItem.recurrenceId) {
             // if this is a single occurrence of a recurring item
-            let newItem = window.calendarItem.parentItem.clone();
-            newItem.recurrenceInfo.removeOccurrenceAt(window.calendarItem.recurrenceId);
-
-            gMainWindow.doTransaction("modify", newItem, newItem.calendar,
-                                      window.calendarItem.parentItem, deleteListener);
+            if (countOccurrences(window.calendarItem) == 1) {
+                // this is the last occurrence, hence we delete the parent item
+                // to not leave a parent item without children in the calendar
+                gMainWindow.doTransaction("delete", window.calendarItem.parentItem,
+                                          window.calendarItem.calendar, null,
+                                          deleteListener);
+            } else {
+                // we just need to remove the occurrence
+                let newItem = window.calendarItem.parentItem.clone();
+                newItem.recurrenceInfo.removeOccurrenceAt(window.calendarItem.recurrenceId);
+                gMainWindow.doTransaction("modify", newItem, newItem.calendar,
+                                          window.calendarItem.parentItem, deleteListener);
+            }
         } else {
             gMainWindow.doTransaction("delete", window.calendarItem, window.calendarItem.calendar,
                                       null, deleteListener);
         }
     }
 }
 
 /**
new file mode 100644
--- /dev/null
+++ b/calendar/test/unit/test_recurrence_utils.js
@@ -0,0 +1,351 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { countOccurrences } = ChromeUtils.import("resource://calendar/modules/calRecurrenceUtils.jsm", null);
+
+function run_test() {
+    do_calendar_startup(run_next_test);
+}
+
+// tests for calRecurrenceUtils.jsm
+/* Incomplete - still missing test coverage for:
+   * recurrenceRule2String
+   * splitRecurrenceRules
+   * checkRecurrenceRule
+*/
+
+function getIcs(aProperties) {
+    let calendar = [
+        "BEGIN:VCALENDAR",
+        "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+        "VERSION:2.0",
+        "BEGIN:VTIMEZONE",
+        "TZID:Europe/Berlin",
+        "BEGIN:DAYLIGHT",
+        "TZOFFSETFROM:+0100",
+        "TZOFFSETTO:+0200",
+        "TZNAME:CEST",
+        "DTSTART:19700329T020000",
+        "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3",
+        "END:DAYLIGHT",
+        "BEGIN:STANDARD",
+        "TZOFFSETFROM:+0200",
+        "TZOFFSETTO:+0100",
+        "TZNAME:CET",
+        "DTSTART:19701025T030000",
+        "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10",
+        "END:STANDARD",
+        "END:VTIMEZONE",
+    ];
+    calendar = calendar.concat(aProperties);
+    calendar = calendar.concat(["END:VCALENDAR"]);
+
+    return calendar.join("\r\n");
+}
+
+add_task(async function countOccurrences_test() {
+    let data = [{
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98000",
+            "SUMMARY:Occurring 3 times until a date",
+            "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 3
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98001",
+            "SUMMARY:Occurring 3 times until a date with one exception in the middle",
+            "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+            "EXDATE;TZID=Europe/Berlin:20180921T120000",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 2
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98002",
+            "SUMMARY:Occurring 3 times until a date with one exception at the end",
+            "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+            "EXDATE;TZID=Europe/Berlin:20180922T120000",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 2
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98003",
+            "SUMMARY:Occurring 3 times until a date with one exception at the beginning",
+            "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+            "EXDATE;TZID=Europe/Berlin:20180920T120000",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 2
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004",
+            "SUMMARY:Occurring 3 times until a date with the middle occurrence moved after the end",
+            "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004",
+            "SUMMARY:The moved occurrence",
+            "RECURRENCE-ID:20180921T100000Z",
+            "DTSTART;TZID=Europe/Berlin:20180924T120000",
+            "DTEND;TZID=Europe/Berlin:20180924T130000",
+            "END:VEVENT",
+        ],
+        expected: 3
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005",
+            "SUMMARY:Occurring 3 times until a date with the middle occurrence moved before the beginning",
+            "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005",
+            "SUMMARY:The moved occurrence",
+            "RECURRENCE-ID:20180921T100000Z",
+            "DTSTART;TZID=Europe/Berlin:20180918T120000",
+            "DTEND;TZID=Europe/Berlin:20180918T130000",
+            "END:VEVENT",
+        ],
+        expected: 3
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98006",
+            "SUMMARY:Occurring 1 times until a date",
+            "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 1
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98007",
+            "SUMMARY:Occurring 1 times until a date with occernce removed",
+            "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z",
+            "EXDATE;TZID=Europe/Berlin:20180920T120000",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 0
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98008",
+            "SUMMARY:Occurring for 3 times",
+            "RRULE:FREQ=DAILY;COUNT=3",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 3
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98009",
+            "SUMMARY:Occurring for 3 times with an exception in the middle",
+            "EXDATE;TZID=Europe/Berlin:20180921T120000",
+            "RRULE:FREQ=DAILY;COUNT=3",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 2
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98010",
+            "SUMMARY:Occurring for 3 times with an exception at the end",
+            "EXDATE;TZID=Europe/Berlin:20180922T120000",
+            "RRULE:FREQ=DAILY;COUNT=3",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 2
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98011",
+            "SUMMARY:Occurring for 3 times with an exception at the beginning",
+            "EXDATE;TZID=Europe/Berlin:20180920T120000",
+            "RRULE:FREQ=DAILY;COUNT=3",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 2
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98012",
+            "SUMMARY:Occurring for 1 time",
+            "RRULE:FREQ=DAILY;COUNT=1",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 1
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98013",
+            "SUMMARY:Occurring for 0 times",
+            "RRULE:FREQ=DAILY;COUNT=1",
+            "EXDATE;TZID=Europe/Berlin:20180920T120000",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 0
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98014",
+            "SUMMARY:Occurring infinitely",
+            "RRULE:FREQ=DAILY",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: null
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98015",
+            "SUMMARY:Non-occurring item",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: null
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98016",
+            "SUMMARY:Occurring for 3 time and 1 rdate",
+            "RRULE:FREQ=DAILY;COUNT=3",
+            "RDATE;TZID=Europe/Berlin:20180923T100000",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 4
+    }, {
+        input: [
+            "BEGIN:VEVENT",
+            "CREATED:20180912T090539Z",
+            "LAST-MODIFIED:20180912T090539Z",
+            "DTSTAMP:20180912T090539Z",
+            "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98017",
+            "SUMMARY:Occurring for 3 rdates",
+            "RDATE;TZID=Europe/Berlin:20180920T120000",
+            "RDATE;TZID=Europe/Berlin:20180921T100000",
+            "RDATE;TZID=Europe/Berlin:20180922T140000",
+            "DTSTART;TZID=Europe/Berlin:20180920T120000",
+            "DTEND;TZID=Europe/Berlin:20180920T130000",
+            "END:VEVENT",
+        ],
+        expected: 3
+    }];
+
+    let i = 0;
+    for (let test of data) {
+        i++;
+
+        let ics = getIcs(test.input);
+        let parser = Cc["@mozilla.org/calendar/ics-parser;1"]
+                       .createInstance(Ci.calIIcsParser);
+        parser.parseString(ics);
+        let items = parser.getItems({});
+
+        ok(items.length > 0, "parsing input suceeded (test #" + i + ")");
+        for (let item of items) {
+            equal(
+                countOccurrences(item),
+                test.expected,
+                "expected number of occurrences (test #" + i + " - '" + item.title + "')"
+            );
+        }
+    }
+});
--- a/calendar/test/unit/xpcshell-shared.ini
+++ b/calendar/test/unit/xpcshell-shared.ini
@@ -37,16 +37,17 @@ skip-if = true # See bug 1481180. reques
 [test_ics_parser.js]
 [test_ics_service.js]
 [test_imip.js]
 [test_items.js]
 [test_l10n_utils.js]
 [test_ltninvitationutils.js]
 [test_providers.js]
 [test_recur.js]
+[test_recurrence_utils.js]
 [test_relation.js]
 [test_rfc3339_parser.js]
 [test_search_service.js]
 [test_startup_service.js]
 [test_storage.js]
 [test_timezone.js]
 [test_timezone_definition.js]
 [test_unifinder_utils.js]