From 7f0b8e4054bf5e16f0e90de5ebe44ac0b4c4f032 Mon Sep 17 00:00:00 2001 From: jkriegshauser Date: Fri, 26 Jan 2024 22:56:54 -0800 Subject: [PATCH] Better fixes for #3291 and the underlying exdate issues (#3342) * Worked around several issues in the RRULE library that were causing deleted calender events to still show, some initial and recurring events to not show, and some event times to be off an hour. (#3291) * Renamed variables in *calendarfetcherutils.js* to be more clear about use of `moment` and js's `Date` class. * Added calendar config option `forceUseCurrentTime` (default:`false`) which will ignore overridden `Date.now` in the config in order to keep some tests consistent. * Added several unit tests for crossing DST in different timezones with excluded events. --- CHANGELOG.md | 7 + modules/default/calendar/calendar.js | 14 +- .../default/calendar/calendarfetcherutils.js | 253 ++++++++---------- tests/configs/modules/calendar/custom.js | 1 + tests/configs/modules/calendar/exdate.js | 31 --- .../calendar/exdate_la_at_midnight_dst.js | 37 +++ .../calendar/exdate_la_at_midnight_std.js | 37 +++ .../calendar/exdate_la_before_midnight.js | 37 +++ .../calendar/exdate_syd_at_midnight_dst.js | 37 +++ .../calendar/exdate_syd_at_midnight_std.js | 37 +++ .../calendar/exdate_syd_before_midnight.js | 37 +++ tests/electron/helpers/global-setup.js | 6 +- tests/electron/modules/calendar_spec.js | 89 +++++- tests/mocks/calendar_test_exdate.ics | 34 --- tests/mocks/exdate_la_at_midnight_dst.ics | 15 ++ tests/mocks/exdate_la_at_midnight_std.ics | 15 ++ tests/mocks/exdate_la_before_midnight.ics | 15 ++ tests/mocks/exdate_syd_at_midnight_dst.ics | 15 ++ tests/mocks/exdate_syd_at_midnight_std.ics | 15 ++ tests/mocks/exdate_syd_before_midnight.ics | 15 ++ 20 files changed, 528 insertions(+), 219 deletions(-) delete mode 100644 tests/configs/modules/calendar/exdate.js create mode 100644 tests/configs/modules/calendar/exdate_la_at_midnight_dst.js create mode 100644 tests/configs/modules/calendar/exdate_la_at_midnight_std.js create mode 100644 tests/configs/modules/calendar/exdate_la_before_midnight.js create mode 100644 tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js create mode 100644 tests/configs/modules/calendar/exdate_syd_at_midnight_std.js create mode 100644 tests/configs/modules/calendar/exdate_syd_before_midnight.js delete mode 100644 tests/mocks/calendar_test_exdate.ics create mode 100644 tests/mocks/exdate_la_at_midnight_dst.ics create mode 100644 tests/mocks/exdate_la_at_midnight_std.ics create mode 100644 tests/mocks/exdate_la_before_midnight.ics create mode 100644 tests/mocks/exdate_syd_at_midnight_dst.ics create mode 100644 tests/mocks/exdate_syd_at_midnight_std.ics create mode 100644 tests/mocks/exdate_syd_before_midnight.ics diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a024297a..6842a60592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,13 @@ _This release is scheduled to be released on 2024-04-01._ - Unneeded file headers (#3358) +## [2.27.0] - UNRELEASED + +### Fixed + +- Worked around several issues in the RRULE library that were causing deleted calender events to still show, some + initial and recurring events to not show, and some event times to be off an hour. (#3291) + ## [2.26.0] - 01-01-2024 Thanks to: @bnitkin, @bugsounet, @dependabot, @jkriegshauser, @kaennchenstruggle, @KristjanESPERANTO and @Ybbet. diff --git a/modules/default/calendar/calendar.js b/modules/default/calendar/calendar.js index fe08a883af..3b10b922d1 100644 --- a/modules/default/calendar/calendar.js +++ b/modules/default/calendar/calendar.js @@ -36,6 +36,7 @@ Module.register("calendar", { hideDuplicates: true, showTimeToday: false, colored: false, + forceUseCurrentTime: false, tableClass: "small", calendars: [ { @@ -567,9 +568,16 @@ Module.register("calendar", { const ONE_HOUR = ONE_MINUTE * 60; const ONE_DAY = ONE_HOUR * 24; - const now = new Date(); - const today = moment().startOf("day"); - const future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + let now, today, future; + if (this.config.forceUseCurrentTime || this.defaults.forceUseCurrentTime) { + now = new Date(); + today = moment().startOf("day"); + future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + } else { + now = new Date(Date.now()); // Can use overridden time + today = moment(now).startOf("day"); + future = moment(now).startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + } let events = []; for (const calendarUrl in this.calendarData) { diff --git a/modules/default/calendar/calendarfetcherutils.js b/modules/default/calendar/calendarfetcherutils.js index c2067296cd..4be451eb51 100644 --- a/modules/default/calendar/calendarfetcherutils.js +++ b/modules/default/calendar/calendarfetcherutils.js @@ -56,7 +56,7 @@ const CalendarFetcherUtils = { event.start.tz = ""; Log.debug(`ical offset=${current_offset} date=${date}`); mm = moment(date); - let x = parseInt(moment(new Date()).utcOffset()); + let x = moment(new Date()).utcOffset(); Log.debug(`net mins=${current_offset * 60 - x}`); mm = mm.add(x - current_offset * 60, "minutes"); @@ -128,24 +128,26 @@ const CalendarFetcherUtils = { }; const eventDate = function (event, time) { - return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time])); + return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time]).startOf("day") : moment(event[time]); }; Log.debug(`There are ${Object.entries(data).length} calendar entries.`); + + const now = new Date(Date.now()); + const todayLocal = moment(now).startOf("day").toDate(); + const futureLocalDate + = moment(now) + .startOf("day") + .add(config.maximumNumberOfDays, "days") + .subtract(1, "seconds") // Subtract 1 second so that events that start on the middle of the night will not repeat. + .toDate(); + Object.entries(data).forEach(([key, event]) => { Log.debug("Processing entry..."); - const now = new Date(); - const today = moment().startOf("day").toDate(); - const future - = moment() - .startOf("day") - .add(config.maximumNumberOfDays, "days") - .subtract(1, "seconds") // Subtract 1 second so that events that start on the middle of the night will not repeat. - .toDate(); - let past = today; + let pastLocalDate = todayLocal; if (config.includePastEvents) { - past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); + pastLocalDate = moment(now).startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); } // FIXME: Ugly fix to solve the facebook birthday issue. @@ -159,33 +161,33 @@ const CalendarFetcherUtils = { if (event.type === "VEVENT") { Log.debug(`Event:\n${JSON.stringify(event)}`); - let startDate = eventDate(event, "start"); - let endDate; + let startMoment = eventDate(event, "start"); + let endMoment; if (typeof event.end !== "undefined") { - endDate = eventDate(event, "end"); + endMoment = eventDate(event, "end"); } else if (typeof event.duration !== "undefined") { - endDate = startDate.clone().add(moment.duration(event.duration)); + endMoment = startMoment.clone().add(moment.duration(event.duration)); } else { if (!isFacebookBirthday) { // make copy of start date, separate storage area - endDate = moment(startDate.format("x"), "x"); + endMoment = moment(startMoment.valueOf()); } else { - endDate = moment(startDate).add(1, "days"); + endMoment = moment(startMoment).add(1, "days"); } } - Log.debug(`start: ${startDate.toDate()}`); - Log.debug(`end:: ${endDate.toDate()}`); + Log.debug(`start: ${startMoment.toDate()}`); + Log.debug(`end:: ${endMoment.toDate()}`); // Calculate the duration of the event for use with recurring events. - let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x")); - Log.debug(`duration: ${duration}`); + const durationMs = endMoment.valueOf() - startMoment.valueOf(); + Log.debug(`duration: ${durationMs}`); // FIXME: Since the parsed json object from node-ical comes with time information // this check could be removed (?) if (event.start.length === 8) { - startDate = startDate.startOf("day"); + startMoment = startMoment.startOf("day"); } const title = CalendarFetcherUtils.getTitleFromEvent(event); @@ -245,11 +247,11 @@ const CalendarFetcherUtils = { const geo = event.geo || false; const description = event.description || false; - if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) { + if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { const rule = event.rrule; - const pastMoment = moment(past); - const futureMoment = moment(future); + const pastMoment = moment(pastLocalDate); + const futureMoment = moment(futureLocalDate); // can cause problems with e.g. birthdays before 1900 if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) { @@ -260,8 +262,8 @@ const CalendarFetcherUtils = { // For recurring events, get the set of start dates that fall within the range // of dates we're looking for. // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time - let pastLocal = 0; - let futureLocal = 0; + let pastLocal; + let futureLocal; if (CalendarFetcherUtils.isFullDayEvent(event)) { Log.debug("fullday"); // if full day event, only use the date part of the ranges @@ -277,17 +279,51 @@ const CalendarFetcherUtils = { pastLocal = pastMoment.toDate(); } else { // otherwise use NOW.. cause we shouldn't use any before now - pastLocal = moment().toDate(); //now + pastLocal = moment(now).toDate(); //now } futureLocal = futureMoment.toDate(); // future } Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`); - let dates = rule.between(pastLocal, futureLocal, true, limitFunction); + const hasByWeekdayRule = rule.options.byweekday !== undefined && rule.options.byweekday !== null; + const oneDayInMs = 24 * 60 * 60 * 1000; + Log.debug(`RRule: ${rule.toString()}`); + rule.options.tzid = null; // RRule gets *very* confused with timezones + let dates = rule.between(new Date(pastLocal.valueOf() - oneDayInMs), new Date(futureLocal.valueOf() + oneDayInMs), true, () => { return true; }); Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`); dates = dates.filter((d) => { if (JSON.stringify(d) === "null") return false; else return true; }); + + // RRule can generate dates with an incorrect recurrence date. Process the array here and apply date correction. + if (hasByWeekdayRule) { + Log.debug("Rule has byweekday, checking for correction"); + dates.forEach((date, index, arr) => { + // NOTE: getTimezoneOffset() is negative of the expected value. For America/Los_Angeles under DST (GMT-7), + // this value is +420. For Australia/Sydney under DST (GMT+11), this value is -660. + const tzOffset = date.getTimezoneOffset() / 60; + const hour = date.getHours(); + if ((tzOffset < 0) && (hour < -tzOffset)) { // east of GMT + Log.debug(`East of GMT (tzOffset: ${tzOffset}) and hour=${hour} < ${-tzOffset}, Subtracting 1 day from ${date}`); + arr[index] = new Date(date.valueOf() - oneDayInMs); + } else if ((tzOffset > 0) && (hour >= (24 - tzOffset))) { // west of GMT + Log.debug(`West of GMT (tzOffset: ${tzOffset}) and hour=${hour} >= 24-${tzOffset}, Adding 1 day to ${date}`); + arr[index] = new Date(date.valueOf() + oneDayInMs); + } + }); + } + + // The dates array from rrule can be confused by DST. If the event was created during DST and we + // are querying a date range during non-DST, rrule can have the incorrect time for the date range. + // Reprocess the array here computing and applying the time offset. + dates.forEach((date, index, arr) => { + let adjustHours = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date); + if (adjustHours !== 0) { + Log.debug(`Applying timezone adjustment hours=${adjustHours} to ${date}`); + arr[index] = new Date(date.valueOf() + (adjustHours * 60 * 60 * 1000)); + } + }); + // The "dates" array contains the set of dates within our desired date range range that are valid // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that // had its date changed from outside the range to inside the range. For the time being, @@ -297,108 +333,35 @@ const CalendarFetcherUtils = { // Would be great if there was a better way to handle this. Log.debug(`event.recurrences: ${event.recurrences}`); if (event.recurrences !== undefined) { - for (let r in event.recurrences) { + for (let dateKey in event.recurrences) { // Only add dates that weren't already in the range we added from the rrule so that - // we don"t double-add those events. - if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) { - dates.push(new Date(r)); + // we don't double-add those events. + let d = new Date(dateKey); + if (!moment(d).isBetween(pastMoment, futureMoment)) { + dates.push(d); } } } + + // Lastly, sometimes rrule doesn't include the event.start even if it is in the requested range. Ensure + // inclusion here. Unfortunately dates.includes() doesn't find it so we have to do forEach(). + { + let found = false; + dates.forEach((d) => { if (d.valueOf() === event.start.valueOf()) found = true; }); + if (!found) { + Log.debug(`event.start=${event.start} was not included in results from rrule; adding`); + dates.splice(0, 0, event.start); + } + } + // Loop through the set of date entries to see which recurrences should be added to our event list. for (let d in dates) { let date = dates[d]; let curEvent = event; + let curDurationMs = durationMs; let showRecurrence = true; - // set the time information in the date to equal the time information in the event - date.setUTCHours(curEvent.start.getUTCHours(), curEvent.start.getUTCMinutes(), curEvent.start.getUTCSeconds(), curEvent.start.getUTCMilliseconds()); - - // Get the offset of today where we are processing - // This will be the correction, we need to apply. - let nowOffset = new Date().getTimezoneOffset(); - // For full day events, the time might be off from RRULE/Luxon problem - // Get time zone offset of the rule calculated event - let dateoffset = date.getTimezoneOffset(); - - // Reduce the time by the following offset. - Log.debug(` recurring date is ${date} offset is ${dateoffset}`); - - let dh = moment(date).format("HH"); - Log.debug(` recurring date is ${date} offset is ${dateoffset / 60} Hour is ${dh}`); - - if (CalendarFetcherUtils.isFullDayEvent(event)) { - Log.debug("Fullday"); - // If the offset is negative (east of GMT), where the problem is - if (dateoffset < 0) { - if (dh < Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - // reduce the time by the offset - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // Apply the correction to the date/time to get it UTC relative - date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date1 fulldate is ${date}`); - } - } else { - // if the timezones are the same, correct date if needed - //if (event.start.tz === moment.tz.guess()) { - // if the date hour is less than the offset - if (24 - dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // apply the correction to the date/time back to right day - date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date2 fulldate is ${date}`); - } - //} - } - } else { - // not full day, but luxon can still screw up the date on the rule processing - // we need to correct the date to get back to the right event for - if (dateoffset < 0) { - // if the date hour is less than the offset - if (dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // Reduce the time by t: - // Apply the correction to the date/time to get it UTC relative - date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date1 is ${date}`); - } - } else { - // if the timezones are the same, correct date if needed - //if (event.start.tz === moment.tz.guess()) { - // if the date hour is less than the offset - if (24 - dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // apply the correction to the date/time back to right day - date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date2 is ${date}`); - } - //} - } - } - startDate = moment(date); - Log.debug(`Corrected startDate: ${startDate.toDate()}`); - - let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date); + startMoment = moment(date); // Remove the time information of each date by using its substring, using the following method: // .toISOString().substring(0,10). @@ -411,30 +374,30 @@ const CalendarFetcherUtils = { if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) { // We found an override, so for this recurrence, use a potentially different title, start date, and duration. curEvent = curEvent.recurrences[dateKey]; - startDate = moment(curEvent.start); - duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x")); + startMoment = moment(curEvent.start); + curDurationMs = curEvent.end.valueOf() - startMoment.valueOf(); } // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) { // This date is an exception date, which means we should skip it in the recurrence pattern. showRecurrence = false; } - Log.debug(`duration: ${duration}`); + Log.debug(`duration: ${curDurationMs}`); - endDate = moment(parseInt(startDate.format("x")) + duration, "x"); - if (startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); + endMoment = moment(startMoment.valueOf() + curDurationMs); + if (startMoment.valueOf() === endMoment.valueOf()) { + endMoment = endMoment.endOf("day"); } const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent); // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add // it to the event list. - if (endDate.isBefore(past) || startDate.isAfter(future)) { + if (endMoment.isBefore(pastLocal) || startMoment.isAfter(futureLocal)) { showRecurrence = false; } - if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { showRecurrence = false; } @@ -442,8 +405,8 @@ const CalendarFetcherUtils = { Log.debug(`saving event: ${description}`); newEvents.push({ title: recurrenceTitle, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + startDate: startMoment.format("x"), + endDate: endMoment.format("x"), fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), recurringEvent: true, class: event.class, @@ -461,43 +424,47 @@ const CalendarFetcherUtils = { // Log.debug("full day event") // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) - if (fullDayEvent && startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); + if (fullDayEvent && startMoment.valueOf() === endMoment.valueOf()) { + endMoment = endMoment.endOf("day"); } if (config.includePastEvents) { // Past event is too far in the past, so skip. - if (endDate < past) { + if (endMoment < pastLocalDate) { return; } } else { // It's not a fullday event, and it is in the past, so skip. - if (!fullDayEvent && endDate < new Date()) { + if (!fullDayEvent && endMoment < now) { return; } // It's a fullday event, and it is before today, So skip. - if (fullDayEvent && endDate <= today) { + if (fullDayEvent && endMoment <= todayLocal) { return; } } // It exceeds the maximumNumberOfDays limit, so skip. - if (startDate > future) { + if (startMoment > futureLocalDate) { return; } - if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { return; } // get correction for date saving and dst change between now and then - let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startDate.toDate()); + let adjustHours = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startMoment.toDate()); + // This shouldn't happen + if (adjustHours) { + Log.warn(`Unexpected timezone adjustment of ${adjustHours} hours on non-recurring event`); + } // Every thing is good. Add it to the list. newEvents.push({ title: title, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + startDate: startMoment.add(adjustHours, "hours").format("x"), + endDate: endMoment.add(adjustHours, "hours").format("x"), fullDayEvent: fullDayEvent, class: event.class, location: location, @@ -578,7 +545,7 @@ const CalendarFetcherUtils = { increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js filterUntil = moment(endDate.format()).subtract(value, increment); - return now < filterUntil.format("x"); + return now < filterUntil.toDate(); } return false; diff --git a/tests/configs/modules/calendar/custom.js b/tests/configs/modules/calendar/custom.js index 4c6deb7838..8552bcbe5c 100644 --- a/tests/configs/modules/calendar/custom.js +++ b/tests/configs/modules/calendar/custom.js @@ -7,6 +7,7 @@ let config = { position: "bottom_bar", config: { customEvents: [{ keyword: "CustomEvent", symbol: "dice", eventClass: "undo" }], + forceUseCurrentTime: true, calendars: [ { maximumEntries: 5, diff --git a/tests/configs/modules/calendar/exdate.js b/tests/configs/modules/calendar/exdate.js deleted file mode 100644 index bb52561aa0..0000000000 --- a/tests/configs/modules/calendar/exdate.js +++ /dev/null @@ -1,31 +0,0 @@ -/* NOTE: calendar_test_exdate.ics has exdate entries for the next 20 years, but without some - * way to set a debug date for tests, this test may become flaky on specific days (i.e. could - * not test easily on leap-years, the BYDAY specified in exdate, etc.) or when the 20 years - * elapses if this project is still in active development ;) - * See issue #3250 - */ -let config = { - timeFormat: 12, - - modules: [ - { - module: "calendar", - position: "bottom_bar", - config: { - maximumEntries: 100, - calendars: [ - { - maximumEntries: 100, - maximumNumberOfDays: 364, - url: "http://localhost:8080/tests/mocks/calendar_test_exdate.ics" - } - ] - } - } - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") { - module.exports = config; -} diff --git a/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js b/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js new file mode 100644 index 0000000000..608c71da96 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_la_at_midnight_dst.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("19 Oct 2023 12:30:00 GMT-07:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_la_at_midnight_std.js b/tests/configs/modules/calendar/exdate_la_at_midnight_std.js new file mode 100644 index 0000000000..6128c97f50 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_at_midnight_std.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_la_at_midnight_std.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("19 Oct 2023 12:30:00 GMT-07:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_la_before_midnight.js b/tests/configs/modules/calendar/exdate_la_before_midnight.js new file mode 100644 index 0000000000..8c886f775a --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_before_midnight.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_la_before_midnight.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("19 Oct 2023 12:30:00 GMT-07:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js b/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js new file mode 100644 index 0000000000..f98b75f4c7 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_syd_at_midnight_dst.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("14 Sep 2023 12:30:00 GMT+10:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js b/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js new file mode 100644 index 0000000000..06c7ff5319 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_syd_at_midnight_std.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("14 Sep 2023 12:30:00 GMT+10:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_syd_before_midnight.js b/tests/configs/modules/calendar/exdate_syd_before_midnight.js new file mode 100644 index 0000000000..849c1f5363 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_before_midnight.js @@ -0,0 +1,37 @@ +/* MagicMirror² Test calendar exdate + * + * By jkriegshauser + * MIT Licensed. + * + * See issue #3250 + * See tests/electron/modules/calendar_spec.js + */ +let config = { + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped + url: "http://localhost:8080/tests/mocks/exdate_syd_before_midnight.ics" + } + ] + } + } + ] +}; + +Date.now = () => { + return new Date("14 Sep 2023 12:30:00 GMT+10:00").valueOf(); +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/electron/helpers/global-setup.js b/tests/electron/helpers/global-setup.js index 164c7f6630..71dfd0ddbe 100644 --- a/tests/electron/helpers/global-setup.js +++ b/tests/electron/helpers/global-setup.js @@ -3,11 +3,11 @@ // https://www.anycodings.com/1questions/958135/can-i-set-the-date-for-playwright-browser const { _electron: electron } = require("playwright"); -exports.startApplication = async (configFilename, systemDate = null, electronParams = ["js/electron.js"]) => { +exports.startApplication = async (configFilename, systemDate = null, electronParams = ["js/electron.js"], timezone = "GMT") => { global.electronApp = null; global.page = null; process.env.MM_CONFIG_FILE = configFilename; - process.env.TZ = "GMT"; + process.env.TZ = timezone; global.electronApp = await electron.launch({ args: electronParams }); await global.electronApp.firstWindow(); @@ -20,7 +20,7 @@ exports.startApplication = async (configFilename, systemDate = null, electronPar if (systemDate) { await global.page.evaluate((systemDate) => { Date.now = () => { - return new Date(systemDate); + return new Date(systemDate).valueOf(); }; }, systemDate); } diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index 2af61a93df..3dcbd36f68 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -44,17 +44,96 @@ describe("Calendar module", () => { }); }); - describe("Exdate check", () => { - it("should show the recurring event 51 times (excluded once) in a 364-day (inclusive) period", async () => { - // test must run on a Thursday - await helpers.startApplication("tests/configs/modules/calendar/exdate.js", "14 Dec 2023 12:30:00 GMT"); + /****************************/ + // LOS ANGELES TESTS: + // In 2023, DST (GMT-7) was until 5 Nov, after which is standard (STD) (GMT-8) time. + // Test takes place on Thu 19 Oct, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be + // 4 events (25 Oct, 1 Nov, (switch to STD), 8 Nov, Nov 15), but 1 Nov and 8 Nov are excluded. + // There are three separate tests: + // * before midnight GMT (3pm local time) + // * at midnight GMT in STD time (4pm local time) + // * at midnight GMT in DST time (5pm local time) + describe("Exdate: LA crossover DST before midnight GMT", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_la_before_midnight.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles"); expect(global.page).not.toBeNull(); const loc = await global.page.locator(".calendar .event"); const elem = loc.first(); await elem.waitFor(); expect(elem).not.toBeNull(); const cnt = await loc.count(); - expect(cnt).toBe(51); + expect(cnt).toBe(2); + }); + }); + + describe("Exdate: LA crossover DST at midnight GMT local STD", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_std.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + describe("Exdate: LA crossover DST at midnight GMT local DST", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_dst.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + + /****************************/ + // SYDNEY TESTS: + // In 2023, standard time (STD) (GMT+10) was until 1 Oct, after which is DST (GMT+11). + // Test takes place on Thu 14 Sep, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be + // 4 events (20 Sep, 27 Sep, (switch to DST), 4 Oct, 11 Oct), but 27 Sep and 4 Oct are excluded. + // There are three separate tests: + // * before midnight GMT (9am local time) + // * at midnight GMT in STD time (10am local time) + // * at midnight GMT in DST time (11am local time) + describe("Exdate: SYD crossover DST before midnight GMT", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_before_midnight.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + describe("Exdate: SYD crossover DST at midnight GMT local STD", () => { + it("LA crossover DST before midnight GMT should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_std.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); + }); + }); + describe("Exdate: SYD crossover DST at midnight GMT local DST", () => { + it("SYD crossover DST at midnight GMT local DST should have 2 events", async () => { + await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney"); + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + const cnt = await loc.count(); + expect(cnt).toBe(2); }); }); }); diff --git a/tests/mocks/calendar_test_exdate.ics b/tests/mocks/calendar_test_exdate.ics deleted file mode 100644 index 8f1f8b9f32..0000000000 --- a/tests/mocks/calendar_test_exdate.ics +++ /dev/null @@ -1,34 +0,0 @@ -BEGIN:VEVENT -DTSTART;TZID=UTC:20231025T181000 -DTEND;TZID=UTC:20231025T195000 -RRULE:FREQ=WEEKLY;BYDAY=WE -EXDATE;TZID=UTC:20231101T181000 -EXDATE;TZID=UTC:20241030T181000 -EXDATE;TZID=UTC:20251029T181000 -EXDATE;TZID=UTC:20261028T181000 -EXDATE;TZID=UTC:20271027T181000 -EXDATE;TZID=UTC:20281025T181000 -EXDATE;TZID=UTC:20291024T181000 -EXDATE;TZID=UTC:20301023T181000 -EXDATE;TZID=UTC:20311022T181000 -EXDATE;TZID=UTC:20321020T181000 -EXDATE;TZID=UTC:20331019T181000 -EXDATE;TZID=UTC:20341018T181000 -EXDATE;TZID=UTC:20351017T181000 -EXDATE;TZID=UTC:20361015T181000 -EXDATE;TZID=UTC:20371014T181000 -EXDATE;TZID=UTC:20381013T181000 -EXDATE;TZID=UTC:20391012T181000 -EXDATE;TZID=UTC:20401010T181000 -EXDATE;TZID=UTC:20411009T181000 -EXDATE;TZID=UTC:20421008T181000 -EXDATE;TZID=UTC:20431007T181000 -DTSTAMP:20231025T233434Z -UID:sdflbkasuhdb5fkauglkb@google.com -CREATED:20230306T193128Z -LAST-MODIFIED:20231024T222515Z -SEQUENCE:0 -STATUS:CONFIRMED -SUMMARY:My Event -TRANSP:OPAQUE -END:VEVENT diff --git a/tests/mocks/exdate_la_at_midnight_dst.ics b/tests/mocks/exdate_la_at_midnight_dst.ics new file mode 100644 index 0000000000..6f8575f5d7 --- /dev/null +++ b/tests/mocks/exdate_la_at_midnight_dst.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=America/Los_Angeles:20231025T170000 +DTEND;TZID=America/Los_Angeles:20231025T180000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=America/Los_Angeles:20231101T170000 +EXDATE;TZID=America/Los_Angeles:20231108T170000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_la_at_midnight_std.ics b/tests/mocks/exdate_la_at_midnight_std.ics new file mode 100644 index 0000000000..553ca10eef --- /dev/null +++ b/tests/mocks/exdate_la_at_midnight_std.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=America/Los_Angeles:20231025T160000 +DTEND;TZID=America/Los_Angeles:20231025T170000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=America/Los_Angeles:20231101T160000 +EXDATE;TZID=America/Los_Angeles:20231108T160000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_la_before_midnight.ics b/tests/mocks/exdate_la_before_midnight.ics new file mode 100644 index 0000000000..5cdb069b45 --- /dev/null +++ b/tests/mocks/exdate_la_before_midnight.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=America/Los_Angeles:20231025T150000 +DTEND;TZID=America/Los_Angeles:20231025T160000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=America/Los_Angeles:20231101T150000 +EXDATE;TZID=America/Los_Angeles:20231108T150000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_syd_at_midnight_dst.ics b/tests/mocks/exdate_syd_at_midnight_dst.ics new file mode 100644 index 0000000000..9a88a10b6d --- /dev/null +++ b/tests/mocks/exdate_syd_at_midnight_dst.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=Australia/Sydney:20230920T110000 +DTEND;TZID=Australia/Sydney:20230920T111000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=Australia/Sydney:20230927T110000 +EXDATE;TZID=Australia/Sydney:20231004T110000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_syd_at_midnight_std.ics b/tests/mocks/exdate_syd_at_midnight_std.ics new file mode 100644 index 0000000000..af444e8cc0 --- /dev/null +++ b/tests/mocks/exdate_syd_at_midnight_std.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=Australia/Sydney:20230920T100000 +DTEND;TZID=Australia/Sydney:20230920T110000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=Australia/Sydney:20230927T100000 +EXDATE;TZID=Australia/Sydney:20231004T100000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/exdate_syd_before_midnight.ics b/tests/mocks/exdate_syd_before_midnight.ics new file mode 100644 index 0000000000..5b5e53d2a2 --- /dev/null +++ b/tests/mocks/exdate_syd_before_midnight.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=Australia/Sydney:20230920T090000 +DTEND;TZID=Australia/Sydney:20230920T100000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=Australia/Sydney:20230927T090000 +EXDATE;TZID=Australia/Sydney:20231004T090000 +DTSTAMP:20231025T233434Z +UID:sdflbkasuhdb5fkauglkb@google.com +CREATED:20230306T193128Z +LAST-MODIFIED:20231024T222515Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:My Event +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file