@remit/calendar-service 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/build.test.ts +113 -0
- package/src/build.ts +311 -0
- package/src/errors.ts +13 -1
- package/src/expand.test.ts +10 -0
- package/src/expand.ts +221 -111
- package/src/index.ts +38 -1
- package/src/project.ts +26 -8
- package/src/put.test.ts +17 -0
- package/src/scope.test.ts +472 -0
- package/src/scope.ts +490 -0
- package/src/time.ts +66 -0
- package/src/window.test.ts +524 -0
- package/src/window.ts +232 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { RecurrenceScope } from "@remit/domain-enums";
|
|
4
|
+
import { expandCalendar } from "./expand.js";
|
|
5
|
+
import { AMSTERDAM_VTIMEZONE, ical, singleEvent } from "./fixtures.js";
|
|
6
|
+
import { parseCalendar } from "./parse.js";
|
|
7
|
+
import { applyScopedDelete, applyScopedUpdate } from "./scope.js";
|
|
8
|
+
|
|
9
|
+
const WEEKLY = ical(
|
|
10
|
+
"BEGIN:VCALENDAR",
|
|
11
|
+
"VERSION:2.0",
|
|
12
|
+
"BEGIN:VEVENT",
|
|
13
|
+
"UID:weekly@example.com",
|
|
14
|
+
"DTSTART:20260907T090000Z",
|
|
15
|
+
"DTEND:20260907T100000Z",
|
|
16
|
+
"SUMMARY:Stand-up",
|
|
17
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
18
|
+
"END:VEVENT",
|
|
19
|
+
"END:VCALENDAR",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const OPEN_ENDED = ical(
|
|
23
|
+
"BEGIN:VCALENDAR",
|
|
24
|
+
"VERSION:2.0",
|
|
25
|
+
"BEGIN:VEVENT",
|
|
26
|
+
"UID:open@example.com",
|
|
27
|
+
"DTSTART:20260907T090000Z",
|
|
28
|
+
"DTEND:20260907T100000Z",
|
|
29
|
+
"SUMMARY:Stand-up",
|
|
30
|
+
"RRULE:FREQ=WEEKLY",
|
|
31
|
+
"END:VEVENT",
|
|
32
|
+
"END:VCALENDAR",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const AMSTERDAM_WEEKLY = ical(
|
|
36
|
+
"BEGIN:VCALENDAR",
|
|
37
|
+
"VERSION:2.0",
|
|
38
|
+
...AMSTERDAM_VTIMEZONE,
|
|
39
|
+
"BEGIN:VEVENT",
|
|
40
|
+
"UID:amsterdam@example.com",
|
|
41
|
+
"DTSTART;TZID=Europe/Amsterdam:20261015T090000",
|
|
42
|
+
"DTEND;TZID=Europe/Amsterdam:20261015T100000",
|
|
43
|
+
"SUMMARY:Stand-up",
|
|
44
|
+
"RRULE:FREQ=WEEKLY;COUNT=4",
|
|
45
|
+
"END:VEVENT",
|
|
46
|
+
"END:VCALENDAR",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const read = async (icalData: string) => {
|
|
50
|
+
const parsed = await parseCalendar(icalData);
|
|
51
|
+
assert.ok(parsed.ok, `expected a parse, got ${JSON.stringify(parsed)}`);
|
|
52
|
+
return parsed.value;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const input = (
|
|
56
|
+
scope: (typeof RecurrenceScope)[keyof typeof RecurrenceScope],
|
|
57
|
+
recurrenceId = "",
|
|
58
|
+
) => ({ scope, recurrenceId, followingUid: "split@example.com" });
|
|
59
|
+
|
|
60
|
+
/** The VEVENT blocks of a resource, so an assertion can name one of them. */
|
|
61
|
+
const events = (icalData: string): string[][] => {
|
|
62
|
+
const lines = icalData.split("\r\n");
|
|
63
|
+
const blocks: string[][] = [];
|
|
64
|
+
let current: string[] | null = null;
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (line === "BEGIN:VEVENT") {
|
|
67
|
+
current = [];
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (line === "END:VEVENT" && current) {
|
|
71
|
+
blocks.push(current);
|
|
72
|
+
current = null;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
current?.push(line);
|
|
76
|
+
}
|
|
77
|
+
return blocks;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe("applyScopedUpdate", () => {
|
|
81
|
+
it("rewrites the master and nothing else under scope=All", async () => {
|
|
82
|
+
const calendar = await read(WEEKLY);
|
|
83
|
+
|
|
84
|
+
const write = await applyScopedUpdate(
|
|
85
|
+
calendar,
|
|
86
|
+
"",
|
|
87
|
+
input(RecurrenceScope.All),
|
|
88
|
+
{ summary: "Stand-up (renamed)" },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
assert.ok(write.ok);
|
|
92
|
+
assert.equal(write.value.kind, "Replace");
|
|
93
|
+
assert.ok(write.value.kind === "Replace");
|
|
94
|
+
const [master, ...rest] = events(write.value.icalData);
|
|
95
|
+
assert.deepEqual(rest, []);
|
|
96
|
+
assert.ok(master?.includes("SUMMARY:Stand-up (renamed)"));
|
|
97
|
+
assert.ok(master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"));
|
|
98
|
+
assert.ok(master?.includes("DTSTART:20260907T090000Z"));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("writes a RECURRENCE-ID override for one occurrence under scope=This", async () => {
|
|
102
|
+
const calendar = await read(WEEKLY);
|
|
103
|
+
|
|
104
|
+
const write = await applyScopedUpdate(
|
|
105
|
+
calendar,
|
|
106
|
+
"",
|
|
107
|
+
input(RecurrenceScope.This, "2026-09-21T09:00:00Z"),
|
|
108
|
+
{ summary: "Stand-up (this week only)", start: "2026-09-21T10:00:00Z" },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
assert.ok(write.ok);
|
|
112
|
+
assert.ok(write.value.kind === "Replace");
|
|
113
|
+
const [master, override] = events(write.value.icalData);
|
|
114
|
+
assert.ok(master?.includes("SUMMARY:Stand-up"));
|
|
115
|
+
assert.ok(
|
|
116
|
+
master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"),
|
|
117
|
+
"the series keeps its own rule",
|
|
118
|
+
);
|
|
119
|
+
assert.ok(override?.includes("RECURRENCE-ID:20260921T090000Z"));
|
|
120
|
+
assert.ok(override?.includes("SUMMARY:Stand-up (this week only)"));
|
|
121
|
+
assert.ok(override?.includes("DTSTART:20260921T100000Z"));
|
|
122
|
+
assert.equal(
|
|
123
|
+
override?.some((line) => line.startsWith("RRULE")),
|
|
124
|
+
false,
|
|
125
|
+
"an override is one occurrence and carries no rule",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("edits the override a resource already carries rather than adding a second", async () => {
|
|
130
|
+
const calendar = await read(
|
|
131
|
+
ical(
|
|
132
|
+
"BEGIN:VCALENDAR",
|
|
133
|
+
"VERSION:2.0",
|
|
134
|
+
"BEGIN:VEVENT",
|
|
135
|
+
"UID:weekly@example.com",
|
|
136
|
+
"DTSTART:20260907T090000Z",
|
|
137
|
+
"DTEND:20260907T100000Z",
|
|
138
|
+
"SUMMARY:Stand-up",
|
|
139
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
140
|
+
"END:VEVENT",
|
|
141
|
+
"BEGIN:VEVENT",
|
|
142
|
+
"UID:weekly@example.com",
|
|
143
|
+
"RECURRENCE-ID:20260914T090000Z",
|
|
144
|
+
"DTSTART:20260914T110000Z",
|
|
145
|
+
"DTEND:20260914T120000Z",
|
|
146
|
+
"SUMMARY:Stand-up (moved)",
|
|
147
|
+
"END:VEVENT",
|
|
148
|
+
"END:VCALENDAR",
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const write = await applyScopedUpdate(
|
|
153
|
+
calendar,
|
|
154
|
+
"",
|
|
155
|
+
input(RecurrenceScope.This, "2026-09-14T09:00:00Z"),
|
|
156
|
+
{ summary: "Stand-up (moved again)" },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
assert.ok(write.ok);
|
|
160
|
+
assert.ok(write.value.kind === "Replace");
|
|
161
|
+
const blocks = events(write.value.icalData);
|
|
162
|
+
assert.equal(blocks.length, 2);
|
|
163
|
+
assert.ok(blocks[1]?.includes("SUMMARY:Stand-up (moved again)"));
|
|
164
|
+
assert.ok(
|
|
165
|
+
blocks[1]?.includes("DTSTART:20260914T110000Z"),
|
|
166
|
+
"the instance keeps where it was moved to",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("splits a counted series into a truncated head and a remainder under scope=Following", async () => {
|
|
171
|
+
const calendar = await read(WEEKLY);
|
|
172
|
+
|
|
173
|
+
const write = await applyScopedUpdate(
|
|
174
|
+
calendar,
|
|
175
|
+
"",
|
|
176
|
+
input(RecurrenceScope.Following, "2026-09-21T09:00:00Z"),
|
|
177
|
+
{ summary: "Stand-up (new format)" },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
assert.ok(write.ok);
|
|
181
|
+
assert.equal(write.value.kind, "Split");
|
|
182
|
+
assert.ok(write.value.kind === "Split");
|
|
183
|
+
|
|
184
|
+
const [head] = events(write.value.icalData);
|
|
185
|
+
assert.ok(
|
|
186
|
+
head?.includes("RRULE:FREQ=WEEKLY;COUNT=2"),
|
|
187
|
+
"the head keeps the two occurrences before the split",
|
|
188
|
+
);
|
|
189
|
+
assert.ok(head?.includes("SUMMARY:Stand-up"));
|
|
190
|
+
assert.ok(head?.includes("DTSTART:20260907T090000Z"));
|
|
191
|
+
|
|
192
|
+
const [tail] = events(write.value.following);
|
|
193
|
+
assert.ok(tail?.includes("UID:split@example.com"));
|
|
194
|
+
assert.ok(tail?.includes("DTSTART:20260921T090000Z"));
|
|
195
|
+
assert.ok(tail?.includes("DTEND:20260921T100000Z"));
|
|
196
|
+
assert.ok(tail?.includes("RRULE:FREQ=WEEKLY;COUNT=3"));
|
|
197
|
+
assert.ok(tail?.includes("SUMMARY:Stand-up (new format)"));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("ends an open series with UNTIL just before the split", async () => {
|
|
201
|
+
const calendar = await read(OPEN_ENDED);
|
|
202
|
+
|
|
203
|
+
const write = await applyScopedUpdate(
|
|
204
|
+
calendar,
|
|
205
|
+
"",
|
|
206
|
+
input(RecurrenceScope.Following, "2026-09-21T09:00:00Z"),
|
|
207
|
+
{ summary: "Stand-up (new format)" },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
assert.ok(write.ok);
|
|
211
|
+
assert.ok(write.value.kind === "Split");
|
|
212
|
+
const [head] = events(write.value.icalData);
|
|
213
|
+
assert.ok(head?.includes("RRULE:FREQ=WEEKLY;UNTIL=20260921T085959Z"));
|
|
214
|
+
const [tail] = events(write.value.following);
|
|
215
|
+
assert.ok(
|
|
216
|
+
tail?.includes("RRULE:FREQ=WEEKLY"),
|
|
217
|
+
"the remainder stays open-ended",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("keeps the anchoring zone when the split series is written with a TZID", async () => {
|
|
222
|
+
const calendar = await read(AMSTERDAM_WEEKLY);
|
|
223
|
+
|
|
224
|
+
const write = await applyScopedUpdate(
|
|
225
|
+
calendar,
|
|
226
|
+
"Europe/Amsterdam",
|
|
227
|
+
// The fourth occurrence. The clocks went back on 25 October, so the
|
|
228
|
+
// same 09:00 local is an hour earlier in UTC than the first three.
|
|
229
|
+
input(RecurrenceScope.Following, "2026-11-05T08:00:00Z"),
|
|
230
|
+
{ summary: "Stand-up (new format)" },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
assert.ok(write.ok);
|
|
234
|
+
assert.ok(write.value.kind === "Split");
|
|
235
|
+
const [tail] = events(write.value.following);
|
|
236
|
+
assert.ok(
|
|
237
|
+
tail?.includes("DTSTART;TZID=Europe/Amsterdam:20261105T090000"),
|
|
238
|
+
`the remainder is anchored in the zone, not the offset: ${tail?.join(" | ")}`,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("treats a split at the first occurrence as the whole series", async () => {
|
|
243
|
+
const calendar = await read(WEEKLY);
|
|
244
|
+
|
|
245
|
+
const write = await applyScopedUpdate(
|
|
246
|
+
calendar,
|
|
247
|
+
"",
|
|
248
|
+
input(RecurrenceScope.Following, "2026-09-07T09:00:00Z"),
|
|
249
|
+
{ summary: "Stand-up (renamed)" },
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
assert.ok(write.ok);
|
|
253
|
+
assert.equal(write.value.kind, "Replace");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("refuses a per-occurrence scope on an event that happens once", async () => {
|
|
257
|
+
const calendar = await read(
|
|
258
|
+
singleEvent("DTSTART:20260907T090000Z", "DTEND:20260907T100000Z"),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const write = await applyScopedUpdate(
|
|
262
|
+
calendar,
|
|
263
|
+
"",
|
|
264
|
+
input(RecurrenceScope.This, "2026-09-07T09:00:00Z"),
|
|
265
|
+
{ summary: "Renamed" },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
assert.ok(!write.ok);
|
|
269
|
+
assert.equal(write.error.code, "NotRecurring");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("refuses a scope that names no occurrence", async () => {
|
|
273
|
+
const calendar = await read(WEEKLY);
|
|
274
|
+
|
|
275
|
+
const missing = await applyScopedUpdate(
|
|
276
|
+
calendar,
|
|
277
|
+
"",
|
|
278
|
+
input(RecurrenceScope.This, ""),
|
|
279
|
+
{},
|
|
280
|
+
);
|
|
281
|
+
assert.ok(!missing.ok);
|
|
282
|
+
assert.equal(missing.error.code, "MissingRecurrenceId");
|
|
283
|
+
|
|
284
|
+
const unknown = await applyScopedUpdate(
|
|
285
|
+
calendar,
|
|
286
|
+
"",
|
|
287
|
+
input(RecurrenceScope.This, "2026-09-22T09:00:00Z"),
|
|
288
|
+
{},
|
|
289
|
+
);
|
|
290
|
+
assert.ok(!unknown.ok);
|
|
291
|
+
assert.equal(unknown.error.code, "UnknownOccurrence");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("applyScopedDelete", () => {
|
|
296
|
+
it("removes the resource under scope=All", async () => {
|
|
297
|
+
const calendar = await read(WEEKLY);
|
|
298
|
+
|
|
299
|
+
const write = await applyScopedDelete(
|
|
300
|
+
calendar,
|
|
301
|
+
"",
|
|
302
|
+
input(RecurrenceScope.All),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
assert.ok(write.ok);
|
|
306
|
+
assert.equal(write.value.kind, "Delete");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("writes an EXDATE for one occurrence under scope=This", async () => {
|
|
310
|
+
const calendar = await read(WEEKLY);
|
|
311
|
+
|
|
312
|
+
const write = await applyScopedDelete(
|
|
313
|
+
calendar,
|
|
314
|
+
"",
|
|
315
|
+
input(RecurrenceScope.This, "2026-09-21T09:00:00Z"),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
assert.ok(write.ok);
|
|
319
|
+
assert.ok(write.value.kind === "Replace");
|
|
320
|
+
const [master] = events(write.value.icalData);
|
|
321
|
+
assert.ok(master?.includes("EXDATE:20260921T090000Z"));
|
|
322
|
+
assert.ok(
|
|
323
|
+
master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"),
|
|
324
|
+
"the rest of the series stays",
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("drops the override an EXDATEd occurrence carried", async () => {
|
|
329
|
+
const calendar = await read(
|
|
330
|
+
ical(
|
|
331
|
+
"BEGIN:VCALENDAR",
|
|
332
|
+
"VERSION:2.0",
|
|
333
|
+
"BEGIN:VEVENT",
|
|
334
|
+
"UID:weekly@example.com",
|
|
335
|
+
"DTSTART:20260907T090000Z",
|
|
336
|
+
"DTEND:20260907T100000Z",
|
|
337
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
338
|
+
"END:VEVENT",
|
|
339
|
+
"BEGIN:VEVENT",
|
|
340
|
+
"UID:weekly@example.com",
|
|
341
|
+
"RECURRENCE-ID:20260914T090000Z",
|
|
342
|
+
"DTSTART:20260914T110000Z",
|
|
343
|
+
"DTEND:20260914T120000Z",
|
|
344
|
+
"END:VEVENT",
|
|
345
|
+
"END:VCALENDAR",
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const write = await applyScopedDelete(
|
|
350
|
+
calendar,
|
|
351
|
+
"",
|
|
352
|
+
input(RecurrenceScope.This, "2026-09-14T09:00:00Z"),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
assert.ok(write.ok);
|
|
356
|
+
assert.ok(write.value.kind === "Replace");
|
|
357
|
+
assert.equal(events(write.value.icalData).length, 1);
|
|
358
|
+
assert.ok(
|
|
359
|
+
events(write.value.icalData)[0]?.includes("EXDATE:20260914T090000Z"),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("truncates the series under scope=Following without removing it", async () => {
|
|
364
|
+
const calendar = await read(WEEKLY);
|
|
365
|
+
|
|
366
|
+
const write = await applyScopedDelete(
|
|
367
|
+
calendar,
|
|
368
|
+
"",
|
|
369
|
+
input(RecurrenceScope.Following, "2026-09-21T09:00:00Z"),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
assert.ok(write.ok);
|
|
373
|
+
assert.ok(write.value.kind === "Replace");
|
|
374
|
+
assert.ok(
|
|
375
|
+
events(write.value.icalData)[0]?.includes("RRULE:FREQ=WEEKLY;COUNT=2"),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("removes the resource when the split would take the first occurrence", async () => {
|
|
380
|
+
const calendar = await read(WEEKLY);
|
|
381
|
+
|
|
382
|
+
const write = await applyScopedDelete(
|
|
383
|
+
calendar,
|
|
384
|
+
"",
|
|
385
|
+
input(RecurrenceScope.Following, "2026-09-07T09:00:00Z"),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
assert.ok(write.ok);
|
|
389
|
+
assert.equal(write.value.kind, "Delete");
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
describe("splitting a series whose DTSTART is not a UTC instant", () => {
|
|
394
|
+
const openEnded = (...dtLines: string[]) =>
|
|
395
|
+
ical(
|
|
396
|
+
"BEGIN:VCALENDAR",
|
|
397
|
+
"VERSION:2.0",
|
|
398
|
+
"BEGIN:VEVENT",
|
|
399
|
+
"UID:series@example.com",
|
|
400
|
+
...dtLines,
|
|
401
|
+
"SUMMARY:Series",
|
|
402
|
+
"RRULE:FREQ=WEEKLY",
|
|
403
|
+
"END:VEVENT",
|
|
404
|
+
"END:VCALENDAR",
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// A zone behind UTC is what exposes it: a UTC UNTIL derived from the split
|
|
408
|
+
// instant lands after the raw value the expander compares it to, so the head
|
|
409
|
+
// keeps the occurrence the tail already starts with.
|
|
410
|
+
const BEHIND_UTC = "America/New_York";
|
|
411
|
+
|
|
412
|
+
const splitAtSecondSlot = async (icalData: string) => {
|
|
413
|
+
const before = expandCalendar(await read(icalData), BEHIND_UTC);
|
|
414
|
+
const at = before.occurrences[1]?.startAt as string;
|
|
415
|
+
const write = await applyScopedUpdate(
|
|
416
|
+
await read(icalData),
|
|
417
|
+
BEHIND_UTC,
|
|
418
|
+
input(RecurrenceScope.Following, at),
|
|
419
|
+
{ summary: "Series (changed)" },
|
|
420
|
+
);
|
|
421
|
+
assert.ok(write.ok, JSON.stringify(write));
|
|
422
|
+
assert.ok(write.value.kind === "Split");
|
|
423
|
+
return {
|
|
424
|
+
at,
|
|
425
|
+
head: expandCalendar(await read(write.value.icalData), BEHIND_UTC),
|
|
426
|
+
tail: expandCalendar(await read(write.value.following), BEHIND_UTC),
|
|
427
|
+
rule: write.value.icalData
|
|
428
|
+
.split("\r\n")
|
|
429
|
+
.find((line) => line.startsWith("RRULE")),
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
for (const [frame, dtLines] of Object.entries({
|
|
434
|
+
"an all-day series": [
|
|
435
|
+
"DTSTART;VALUE=DATE:20260601",
|
|
436
|
+
"DTEND;VALUE=DATE:20260602",
|
|
437
|
+
],
|
|
438
|
+
"a floating series": ["DTSTART:20260601T090000", "DTEND:20260601T100000"],
|
|
439
|
+
"a series naming a zone the resource does not define": [
|
|
440
|
+
"DTSTART;TZID=Europe/Amsterdam:20260601T090000",
|
|
441
|
+
"DTEND;TZID=Europe/Amsterdam:20260601T100000",
|
|
442
|
+
],
|
|
443
|
+
"a UTC series": ["DTSTART:20260601T090000Z", "DTEND:20260601T100000Z"],
|
|
444
|
+
})) {
|
|
445
|
+
it(`leaves the split occurrence to the remainder for ${frame}`, async () => {
|
|
446
|
+
const { at, head, tail } = await splitAtSecondSlot(openEnded(...dtLines));
|
|
447
|
+
|
|
448
|
+
assert.equal(
|
|
449
|
+
head.occurrences.some((occurrence) => occurrence.startAt === at),
|
|
450
|
+
false,
|
|
451
|
+
"the truncated head still produced the occurrence it was split at",
|
|
452
|
+
);
|
|
453
|
+
assert.equal(tail.occurrences[0]?.startAt, at);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
it("writes UNTIL as a date for an all-day series, as RFC 5545 3.3.10 requires", async () => {
|
|
458
|
+
const { rule } = await splitAtSecondSlot(
|
|
459
|
+
openEnded("DTSTART;VALUE=DATE:20260601", "DTEND;VALUE=DATE:20260602"),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
assert.equal(rule, "RRULE:FREQ=WEEKLY;UNTIL=20260607");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("writes UNTIL in UTC for a series that names an instant", async () => {
|
|
466
|
+
const { rule } = await splitAtSecondSlot(
|
|
467
|
+
openEnded("DTSTART:20260601T090000Z", "DTEND:20260601T100000Z"),
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
assert.equal(rule, "RRULE:FREQ=WEEKLY;UNTIL=20260608T085959Z");
|
|
471
|
+
});
|
|
472
|
+
});
|