chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:19:24 +08:00
commit dbe3ade0dc
449 changed files with 71828 additions and 0 deletions
@@ -0,0 +1,130 @@
global.moment = require("moment-timezone");
const ical = require("node-ical");
const moment = require("moment-timezone");
const defaults = require("../../../../../js/defaults");
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
const CalendarFetcher = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcher`);
const makeFetcher = (options = {}) => new CalendarFetcher(
options.url ?? "http://test.example.com/cal.ics",
options.reloadInterval ?? 60000,
options.excludedEvents ?? [],
options.maximumEntries ?? 10,
options.maximumNumberOfDays ?? 365,
options.auth ?? null,
options.includePastEvents ?? false,
options.selfSignedCert ?? false
);
// Triggers a fetch and resolves once the fetcher finishes (success or error).
// On error, resolves with the errorInfo object so tests can inspect it.
const emitResponse = (fetcher, response) => new Promise((resolve) => {
fetcher.onReceive(resolve);
fetcher.onError((_, errorInfo) => resolve(errorInfo));
fetcher.httpFetcher.emit("response", response);
});
const futureEventICS = () => {
const start = moment().add(1, "hour");
const end = moment().add(2, "hours");
return [
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
`DTSTART:${start.utc().format("YYYYMMDDTHHmmss")}Z`,
`DTEND:${end.utc().format("YYYYMMDDTHHmmss")}Z`,
"UID:future-1@test",
"SUMMARY:Future Event",
"END:VEVENT",
"END:VCALENDAR"
].join("\r\n");
};
describe("CalendarFetcher", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("304 handling", () => {
it("keeps previously fetched events when a 304 Not Modified response arrives", async () => {
const fetcher = makeFetcher();
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
expect(fetcher.events).toHaveLength(1);
// 304 Not Modified has an empty body: events must be preserved
await emitResponse(fetcher, new Response(null, { status: 304 }));
expect(fetcher.events).toHaveLength(1);
});
});
describe("error handling", () => {
it("forwards HTTP fetch errors to onError callback", () => {
const fetcher = makeFetcher();
const onError = vi.fn();
const errorInfo = { errorType: "NETWORK_ERROR", message: "boom" };
fetcher.onError(onError);
fetcher.httpFetcher.emit("error", errorInfo);
expect(onError).toHaveBeenCalledWith(fetcher, errorInfo);
});
it("keeps existing events and reports PARSE_ERROR when parsing fails", async () => {
const fetcher = makeFetcher();
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
expect(fetcher.events).toHaveLength(1);
vi.spyOn(ical.async, "parseICS").mockRejectedValueOnce(new Error("invalid ics"));
const error = await emitResponse(fetcher, new Response("BROKEN", { status: 200 }));
expect(fetcher.events).toHaveLength(1);
expect(error).toMatchObject({
errorType: "PARSE_ERROR",
translationKey: "MODULE_ERROR_UNSPECIFIED",
url: "http://test.example.com/cal.ics"
});
});
});
describe("delegation and refetch", () => {
it("delegates fetchCalendar to HTTPFetcher.startPeriodicFetch", () => {
const fetcher = makeFetcher();
const startSpy = vi.spyOn(fetcher.httpFetcher, "startPeriodicFetch");
fetcher.fetchCalendar();
expect(startSpy).toHaveBeenCalledTimes(1);
});
it("shouldRefetch respects reload interval boundaries", () => {
const fetcher = makeFetcher();
expect(fetcher.shouldRefetch()).toBe(true);
fetcher.lastFetch = Date.now() - 59999;
expect(fetcher.shouldRefetch()).toBe(false);
fetcher.lastFetch = Date.now() - 60000;
expect(fetcher.shouldRefetch()).toBe(true);
});
it("passes configured filter options to CalendarFetcherUtils.filterEvents", async () => {
const excludedEvents = ["Do not show me"];
const filterSpy = vi.spyOn(CalendarFetcherUtils, "filterEvents");
const fetcher = makeFetcher({ excludedEvents, maximumEntries: 7, maximumNumberOfDays: 30, includePastEvents: true });
await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 }));
expect(filterSpy).toHaveBeenCalledWith(expect.any(Object), {
excludedEvents,
includePastEvents: true,
maximumEntries: 7,
maximumNumberOfDays: 30
});
});
});
});
@@ -0,0 +1,32 @@
global.moment = require("moment-timezone");
const defaults = require("../../../js/defaults");
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
describe("Calendar fetcher utils test", () => {
const defaultConfig = {
excludedEvents: []
};
describe("filterEvents", () => {
it("no events, not crash", () => {
const base = moment().startOf("day").add(12, "hours");
const minusOneHour = base.clone().subtract(1, "hours").toDate();
const minusTwoHours = base.clone().subtract(2, "hours").toDate();
const plusOneHour = base.clone().add(1, "hours").toDate();
const plusTwoHours = base.clone().add(2, "hours").toDate();
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" },
ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" },
upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" }
},
defaultConfig
);
expect(filteredEvents).toHaveLength(0);
});
});
});
@@ -0,0 +1,523 @@
global.moment = require("moment-timezone");
const ical = require("node-ical");
const moment = require("moment-timezone");
const defaults = require("../../../../../js/defaults");
const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`);
describe("Calendar fetcher utils test", () => {
const defaultConfig = {
excludedEvents: [],
includePastEvents: false,
maximumEntries: 10,
maximumNumberOfDays: 367
};
describe("filterEvents", () => {
it("should return only ongoing and upcoming non full day events", () => {
const minusOneHour = moment().subtract(1, "hours").toDate();
const minusTwoHours = moment().subtract(2, "hours").toDate();
const plusOneHour = moment().add(1, "hours").toDate();
const plusTwoHours = moment().add(2, "hours").toDate();
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" },
ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" },
upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" }
},
defaultConfig
);
expect(filteredEvents).toHaveLength(2);
expect(filteredEvents[0].title).toBe("ongoingEvent");
expect(filteredEvents[1].title).toBe("upcomingEvent");
});
it("should return only ongoing and upcoming full day events", () => {
const yesterday = moment().subtract(1, "days").startOf("day").toDate();
const today = moment().startOf("day").toDate();
const tomorrow = moment().add(1, "days").startOf("day").toDate();
const dayAfterTomorrow = moment().add(2, "days").startOf("day").toDate();
// Mark as DATE-only (full-day) events per ICS convention
yesterday.dateOnly = true;
today.dateOnly = true;
tomorrow.dateOnly = true;
dayAfterTomorrow.dateOnly = true;
// ICS convention: DTEND for a full-day event is the exclusive next day
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
pastEvent: { type: "VEVENT", start: yesterday, end: today, summary: "pastEvent" },
ongoingEvent: { type: "VEVENT", start: today, end: tomorrow, summary: "ongoingEvent" },
upcomingEvent: { type: "VEVENT", start: tomorrow, end: dayAfterTomorrow, summary: "upcomingEvent" }
},
defaultConfig
);
expect(filteredEvents).toHaveLength(2);
expect(filteredEvents[0].title).toBe("ongoingEvent");
expect(filteredEvents[1].title).toBe("upcomingEvent");
});
it("should hide excluded event with 'until' when far away and show it when close", () => {
// An event ending in 10 days with until='3 days' should be hidden now
const farStart = moment().add(9, "days").toDate();
const farEnd = moment().add(10, "days").toDate();
// An event ending in 1 day with until='3 days' should be shown (within 3 days of end)
const closeStart = moment().add(1, "hours").toDate();
const closeEnd = moment().add(1, "days").toDate();
const config = {
...defaultConfig,
excludedEvents: [{ filterBy: "Payment", until: "3 days" }]
};
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
farPayment: { type: "VEVENT", start: farStart, end: farEnd, summary: "Payment due" },
closePayment: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Payment reminder" },
normalEvent: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Normal event" }
},
config
);
// farPayment should be hidden (now < endDate - 3 days)
// closePayment should show (now >= endDate - 3 days)
// normalEvent should show (not matched by filter)
const titles = filteredEvents.map((e) => e.title);
expect(titles).not.toContain("Payment due");
expect(titles).toContain("Payment reminder");
expect(titles).toContain("Normal event");
});
it("should fully exclude event when excludedEvents has no 'until'", () => {
const start = moment().add(1, "hours").toDate();
const end = moment().add(2, "hours").toDate();
const config = {
...defaultConfig,
excludedEvents: ["Hidden"]
};
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
hidden: { type: "VEVENT", start, end, summary: "Hidden event" },
visible: { type: "VEVENT", start, end, summary: "Visible event" }
},
config
);
expect(filteredEvents).toHaveLength(1);
expect(filteredEvents[0].title).toBe("Visible event");
});
it("should return the correct times when recurring events pass through daylight saving time", () => {
const data = ical.parseICS(`BEGIN:VEVENT
DTSTART;TZID=Europe/Amsterdam:20250311T090000
DTEND;TZID=Europe/Amsterdam:20250311T091500
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
DTSTAMP:20250531T091103Z
ORGANIZER;CN=test:mailto:test@test.com
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
CREATED:20230111T114612Z
DESCRIPTION:Test
LAST-MODIFIED:20250528T071312Z
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:Test
TRANSP:OPAQUE
END:VEVENT`);
const filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig);
const januaryFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "01-01");
const julyFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "07-01");
let januaryMoment = moment(`${moment(januaryFirst[0].startDate, "x").format("YYYY")}-01-01T09:00:00`)
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
let julyMoment = moment(`${moment(julyFirst[0].startDate, "x").format("YYYY")}-07-01T09:00:00`)
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
expect(januaryFirst[0].startDate).toEqual(januaryMoment.format("x"));
expect(julyFirst[0].startDate).toEqual(julyMoment.format("x"));
});
it("should return the correct moments based on the timezone given", () => {
const data = ical.parseICS(`BEGIN:VEVENT
DTSTART;TZID=Europe/Amsterdam:20250311T090000
DTEND;TZID=Europe/Amsterdam:20250311T091500
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
DTSTAMP:20250531T091103Z
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
SUMMARY:Test
END:VEVENT`);
const instances = CalendarFetcherUtils.expandRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days"));
const januaryFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "01-01");
const julyFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "07-01");
// The underlying timestamps must represent 09:00 Amsterdam time, regardless of local timezone
expect(januaryFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+01:00");
expect(julyFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+02:00");
});
it("should return correct day-of-week for full-day recurring events across DST transitions", () => {
// Test case for GitHub issue #3976: recurring full-day events showing on wrong day
// This happens when DST transitions change the UTC offset between occurrences
const data = ical.parseICS(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;VALUE=DATE:20251027
DTEND;VALUE=DATE:20251028
RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3
DTSTAMP:20260103T123138Z
UID:dst-test@google.com
SUMMARY:Weekly Monday Event
END:VEVENT
END:VCALENDAR`);
// Simulate calendar with timezone (e.g., from X-WR-TIMEZONE or user config)
// This is how MagicMirror handles full-day events from calendars with timezones
data["dst-test@google.com"].start.tz = "America/Chicago";
const pastMoment = moment("2025-10-01");
const futureMoment = moment("2025-11-30");
const instances = CalendarFetcherUtils.expandRecurringEvent(data["dst-test@google.com"], pastMoment, futureMoment);
const startMoments = instances.map((i) => i.startMoment);
// All occurrences should be on Monday (day() === 1) at midnight
// Oct 27, 2025 - Before DST ends
// Nov 3, 2025 - After DST ends (this was showing as Sunday before the fix)
// Nov 10, 2025 - After DST ends
expect(startMoments).toHaveLength(3);
expect(startMoments[0].day()).toBe(1); // Monday
expect(startMoments[0].format("YYYY-MM-DD")).toBe("2025-10-27");
expect(startMoments[0].hour()).toBe(0); // Midnight
expect(startMoments[1].day()).toBe(1); // Monday (not Sunday!)
expect(startMoments[1].format("YYYY-MM-DD")).toBe("2025-11-03");
expect(startMoments[1].hour()).toBe(0); // Midnight
expect(startMoments[2].day()).toBe(1); // Monday
expect(startMoments[2].format("YYYY-MM-DD")).toBe("2025-11-10");
expect(startMoments[2].hour()).toBe(0); // Midnight
});
it("should show Facebook birthday events in the current year, not in the birth year", () => {
// Facebook birthday calendars use DTSTART with the actual birth year (e.g. 1990),
// which previously caused rrule.js to return the wrong year occurrence.
// With rrule-temporal this works correctly without any special-casing.
const data = ical.parseICS(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;VALUE=DATE:19900215
RRULE:FREQ=YEARLY
DTSTAMP:20260101T000000Z
UID:birthday_123456789@facebook.com
SUMMARY:Jane Doe's Birthday
END:VEVENT
END:VCALENDAR`);
const thisYear = moment().year();
const filteredEvents = CalendarFetcherUtils.filterEvents(data, {
...defaultConfig,
maximumNumberOfDays: 366
});
const birthdayEvents = filteredEvents.filter((e) => e.title === "Jane Doe's Birthday");
expect(birthdayEvents.length).toBeGreaterThanOrEqual(1);
// The event must expand to a recent year — NOT to the birth year 1990.
// It should be the current or next year depending on whether Feb 15 has already passed.
const startYear = moment(birthdayEvents[0].startDate, "x").year();
expect(startYear).toBeGreaterThanOrEqual(thisYear);
expect(startYear).toBeLessThanOrEqual(thisYear + 1);
});
it("should produce a correctly shaped event object with all required fields", () => {
const start = moment().add(1, "day").startOf("hour").toDate();
const end = moment().add(1, "day").startOf("hour").add(1, "hour")
.toDate();
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
event1: {
type: "VEVENT",
start,
end,
summary: "Team Meeting",
description: "Agenda TBD",
location: "Room 42",
geo: { lat: 52.52, lon: 13.4 },
class: "PUBLIC",
uid: "shaped-event@test"
}
},
defaultConfig
);
expect(filteredEvents).toHaveLength(1);
const ev = filteredEvents[0];
expect(ev.title).toBe("Team Meeting");
expect(ev.startDate).toBe(moment(start).format("x"));
expect(ev.endDate).toBe(moment(end).format("x"));
expect(ev.fullDayEvent).toBe(false);
expect(ev.recurringEvent).toBe(false);
expect(ev.class).toBe("PUBLIC");
expect(ev.firstYear).toBe(moment(start).year());
expect(ev.location).toBe("Room 42");
expect(ev.geo).toEqual({ lat: 52.52, lon: 13.4 });
expect(ev.description).toBe("Agenda TBD");
});
it("should return correct firstYear for a full-day event on January 1st", () => {
// node-ical creates DATE-only events with the local Date constructor: new Date(year, month, day).
// getFullYear() on a locally-constructed date always returns the correct calendar year
// regardless of the server's UTC offset — guard against regressions that switch to getUTCFullYear().
const data = ical.parseICS(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;VALUE=DATE:19900101
DTEND;VALUE=DATE:19900102
RRULE:FREQ=YEARLY
UID:newyear-birthday@test
SUMMARY:New Year Baby
END:VEVENT
END:VCALENDAR`);
const filteredEvents = CalendarFetcherUtils.filterEvents(data, {
...defaultConfig,
maximumNumberOfDays: 366
});
const birthday = filteredEvents.find((e) => e.title === "New Year Baby");
expect(birthday).toBeDefined();
expect(birthday.firstYear).toBe(1990);
});
});
describe("calculateFilterWindow", () => {
it("ends maximumNumberOfDays after today's midnight", () => {
const [, end] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
expect(end).toEqual(moment().startOf("day").add(30, "days").toDate());
});
it("starts now when includePastEvents is false", () => {
const before = Date.now();
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
const after = Date.now();
expect(start.getTime()).toBeGreaterThanOrEqual(before);
expect(start.getTime()).toBeLessThanOrEqual(after);
});
it("starts maximumNumberOfDays before today's midnight when includePastEvents is true", () => {
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: true, maximumNumberOfDays: 30 });
expect(start).toEqual(moment().startOf("day").subtract(30, "days").toDate());
});
});
describe("expandRecurringEvent", () => {
it("should extend end to end-of-day when event has no DTEND", () => {
// node-ical sets end === start when DTEND is absent; our code extends to endOf("day")
const data = ical.parseICS(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20260222T100000Z
UID:no-end-test@test
SUMMARY:No End Event
END:VEVENT
END:VCALENDAR`);
const instances = CalendarFetcherUtils.expandRecurringEvent(data["no-end-test@test"], moment("2026-02-20"), moment("2026-02-24"));
expect(instances).toHaveLength(1);
expect(instances[0].endMoment.format("HH:mm:ss")).toBe("23:59:59");
});
it("should apply RECURRENCE-ID overrides (moved single occurrence)", () => {
// A weekly event on Mondays at 10:00, but the second occurrence is moved to Tuesday 14:00
const data = ical.parseICS(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20260302T100000
DTEND;TZID=Europe/Berlin:20260302T110000
RRULE:FREQ=WEEKLY;COUNT=3
UID:recurrence-override@test
SUMMARY:Weekly Standup
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20260310T140000
DTEND;TZID=Europe/Berlin:20260310T150000
RECURRENCE-ID;TZID=Europe/Berlin:20260309T100000
UID:recurrence-override@test
SUMMARY:Moved Standup
END:VEVENT
END:VCALENDAR`);
const instances = CalendarFetcherUtils.expandRecurringEvent(
data["recurrence-override@test"],
moment("2026-03-01"),
moment("2026-03-31")
);
expect(instances).toHaveLength(3);
// First occurrence: Monday March 2, 10:00 (unchanged)
expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-02 10:00");
expect(CalendarFetcherUtils.getTitleFromEvent(instances[0].event)).toBe("Weekly Standup");
// Second occurrence: moved to Tuesday March 10, 14:00
expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-10 14:00");
expect(CalendarFetcherUtils.getTitleFromEvent(instances[1].event)).toBe("Moved Standup");
// Third occurrence: Monday March 16, 10:00 (unchanged)
expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-16 10:00");
});
it("should handle events with DURATION instead of DTEND", () => {
// RFC 5545 allows DURATION as alternative to DTEND
const data = ical.parseICS(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20260315T090000Z
DURATION:PT1H30M
UID:duration-test@test
SUMMARY:Duration Event
END:VEVENT
END:VCALENDAR`);
const instances = CalendarFetcherUtils.expandRecurringEvent(
data["duration-test@test"],
moment("2026-03-14"),
moment("2026-03-16")
);
expect(instances).toHaveLength(1);
// End should be 90 minutes after start
const durationMinutes = instances[0].endMoment.diff(instances[0].startMoment, "minutes");
expect(durationMinutes).toBe(90);
});
it("should handle recurring events with DURATION instead of DTEND", () => {
const data = ical.parseICS(`BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20260301T080000
DURATION:PT45M
RRULE:FREQ=DAILY;COUNT=3
UID:recurring-duration@test
SUMMARY:Daily Scrum
END:VEVENT
END:VCALENDAR`);
const instances = CalendarFetcherUtils.expandRecurringEvent(
data["recurring-duration@test"],
moment("2026-02-28"),
moment("2026-03-05")
);
expect(instances).toHaveLength(3);
for (const inst of instances) {
const durationMinutes = inst.endMoment.diff(inst.startMoment, "minutes");
expect(durationMinutes).toBe(45);
}
expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-01");
expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-02");
expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-03");
});
});
describe("filterEvents error handling", () => {
it("should skip a broken event but still return other valid events", () => {
const start = moment().add(1, "hours").toDate();
const end = moment().add(2, "hours").toDate();
vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => {
throw new TypeError("invalid rrule");
});
const result = CalendarFetcherUtils.filterEvents(
{
brokenEvent: { type: "VEVENT", start, end, summary: "Broken" },
goodEvent: { type: "VEVENT", start, end, summary: "Good" }
},
defaultConfig
);
expect(result).toHaveLength(1);
expect(result[0].title).toBe("Good");
});
it("should let expandRecurringEvent throw through directly", () => {
vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => {
throw new TypeError("invalid rrule");
});
const event = { type: "VEVENT", start: new Date(), end: new Date(), summary: "Broken Event" };
expect(() => CalendarFetcherUtils.expandRecurringEvent(event, moment(), moment().add(1, "days"))).toThrow("invalid rrule");
});
});
describe("unwrapParameterValue", () => {
it("should return the val of a ParameterValue object", () => {
expect(CalendarFetcherUtils.unwrapParameterValue({ val: "Text", params: { LANGUAGE: "de" } })).toBe("Text");
});
it("should return a plain string unchanged", () => {
expect(CalendarFetcherUtils.unwrapParameterValue("plain")).toBe("plain");
});
it("should return falsy values unchanged", () => {
expect(CalendarFetcherUtils.unwrapParameterValue(undefined)).toBeUndefined();
expect(CalendarFetcherUtils.unwrapParameterValue(false)).toBe(false);
});
});
describe("getTitleFromEvent", () => {
it("should return summary string directly", () => {
expect(CalendarFetcherUtils.getTitleFromEvent({ summary: "My Event" })).toBe("My Event");
});
it("should unwrap ParameterValue summary", () => {
expect(CalendarFetcherUtils.getTitleFromEvent({ summary: { val: "My Event", params: {} } })).toBe("My Event");
});
it("should fall back to description string", () => {
expect(CalendarFetcherUtils.getTitleFromEvent({ description: "Desc" })).toBe("Desc");
});
it("should unwrap ParameterValue description as fallback title", () => {
expect(CalendarFetcherUtils.getTitleFromEvent({ description: { val: "Desc", params: { LANGUAGE: "de" } } })).toBe("Desc");
});
it("should return 'Event' when neither summary nor description is present", () => {
expect(CalendarFetcherUtils.getTitleFromEvent({})).toBe("Event");
});
});
describe("filterEvents with ParameterValue properties", () => {
it("should handle DESCRIPTION;LANGUAGE=de and LOCATION;LANGUAGE=de without [object Object]", () => {
const start = moment().add(1, "hours").toDate();
const end = moment().add(2, "hours").toDate();
const filteredEvents = CalendarFetcherUtils.filterEvents(
{
event1: {
type: "VEVENT",
start,
end,
summary: "Test",
description: { val: "Beschreibung", params: { LANGUAGE: "de" } },
location: { val: "Berlin", params: { LANGUAGE: "de" } }
}
},
defaultConfig
);
expect(filteredEvents).toHaveLength(1);
expect(filteredEvents[0].description).toBe("Beschreibung");
expect(filteredEvents[0].location).toBe("Berlin");
});
});
});
@@ -0,0 +1,155 @@
global.moment = require("moment");
const defaults = require("../../../../../js/defaults");
const CalendarUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarutils`);
describe("Calendar utils tests", () => {
describe("capFirst", () => {
const words = {
rodrigo: "Rodrigo",
"123m": "123m",
"magic mirror": "Magic mirror",
",a": ",a",
ñandú: "Ñandú"
};
Object.keys(words).forEach((word) => {
it(`for '${word}' should return '${words[word]}'`, () => {
expect(CalendarUtils.capFirst(word)).toBe(words[word]);
});
});
it("should not capitalize other letters", () => {
expect(CalendarUtils.capFirst("event")).not.toBe("EVent");
});
});
describe("getLocaleSpecification", () => {
it("should return a valid moment.LocaleSpecification for a 12-hour format", () => {
expect(CalendarUtils.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: "h:mm A" } });
});
it("should return a valid moment.LocaleSpecification for a 24-hour format", () => {
expect(CalendarUtils.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: "HH:mm" } });
});
it("should return the current system locale when called without timeFormat number", () => {
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat("LT") } });
});
it("should return a 12-hour longDateFormat when using the 'en' locale", () => {
const localeBackup = moment.locale();
moment.locale("en");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 12-hour longDateFormat when using the 'au' locale", () => {
const localeBackup = moment.locale();
moment.locale("au");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 12-hour longDateFormat when using the 'eg' locale", () => {
const localeBackup = moment.locale();
moment.locale("eg");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'nl' locale", () => {
const localeBackup = moment.locale();
moment.locale("nl");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'fr' locale", () => {
const localeBackup = moment.locale();
moment.locale("fr");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
it("should return a 24-hour longDateFormat when using the 'uk' locale", () => {
const localeBackup = moment.locale();
moment.locale("uk");
expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } });
moment.locale(localeBackup);
});
});
describe("shorten", () => {
it("should not shorten if short enough", () => {
expect(CalendarUtils.shorten("Event 1", 10, false, 1)).toBe("Event 1");
});
it("should shorten into one line", () => {
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 1)).toBe("Example …");
});
it("should shorten into three lines", () => {
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 3)).toBe("Example <br>event at 12 o <br>clock");
});
it("should not shorten into three lines if wrap is false", () => {
expect(CalendarUtils.shorten("Example event at 12 o clock", 10, false, 3)).toBe("Example ev…");
});
const strings = {
" String with whitespace at the beginning that needs trimming": { length: 16, return: "String with whit…" },
"long string that needs shortening": { length: 16, return: "long string that…" },
"short string": { length: 16, return: "short string" },
"long string with no maxLength defined": { return: "long string with no maxLength defined" }
};
Object.keys(strings).forEach((string) => {
it(`for '${string}' should return '${strings[string].return}'`, () => {
expect(CalendarUtils.shorten(string, strings[string].length)).toBe(strings[string].return);
});
});
it("should return an empty string if shorten is called with a non-string", () => {
expect(CalendarUtils.shorten(100)).toBe("");
});
it("should not shorten the string if shorten is called with a non-number maxLength", () => {
expect(CalendarUtils.shorten("This is a test string", "This is not a number")).toBe("This is a test string");
});
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)", () => {
expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", 20, true)).toBe(
"This is a <br>wrapEvent test. Should wrap <br>the string instead of <br>shorten it if called with <br>wrapEvent = true"
);
});
it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)", () => {
expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", undefined, true)).toBe(
"This is a wrapEvent <br>test. Should wrap the string <br>instead of shorten it if called <br>with wrapEvent = true"
);
});
it("should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", () => {
expect(CalendarUtils.shorten("This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", undefined, true, 2)).toBe(
"This is a wrapEvent and <br>maxTitleLines test. Should wrap and …"
);
});
});
describe("titleTransform and shorten combined", () => {
it("should replace the birthday and wrap nicely", () => {
const transformedTitle = CalendarUtils.titleTransform("Michael Teeuw's birthday", [{ search: "'s birthday", replace: "" }]);
expect(CalendarUtils.shorten(transformedTitle, 10, true, 2)).toBe("Michael <br>Teeuw");
});
});
describe("titleTransform with yearmatchgroup", () => {
it("should replace the birthday and wrap nicely", () => {
const transformedTitle = CalendarUtils.titleTransform("Luciella '2000", [{ search: "^([^']*) '(\\d{4})$", replace: "$1 ($2.)", yearmatchgroup: 2 }]);
const expectedResult = `Luciella (${new Date().getFullYear() - 2000}.)`;
expect(transformedTitle).toBe(expectedResult);
});
});
});