import { describe, expect, test } from 'bun:test'; import { setProperty } from './useDavEdit'; import { parseEvents } from '../../sidecar/ical'; // setProperty is the whole editing story: it decides what an edit keeps and what it destroys. These // cover the two ways it can quietly destroy something — dropping a property's parameters, and leaving // half of a folded value behind. const ICS = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:abc', 'DTSTAMP:20260827T100000Z', 'DTSTART;TZID=Europe/Lisbon:20260828T130000', 'DTEND;TZID=Europe/Lisbon:20260828T140000', 'SUMMARY:Before', 'RRULE:FREQ=WEEKLY', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'TRIGGER:-PT15M', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); describe('setProperty', () => { test('changes only the line it owns', () => { const out = setProperty(ICS, 'SUMMARY', 'After'); expect(out.split('\r\n').length).toBe(ICS.split('\r\n').length); expect(out).toContain('SUMMARY:After'); expect(out).toContain('RRULE:FREQ=WEEKLY'); expect(out).toContain('BEGIN:VALARM'); }); test('keeps a property that carries parameters', () => { const out = setProperty(ICS, 'DTSTART', '20260901T090000'); expect(out).toContain('DTSTART;TZID=Europe/Lisbon:20260901T090000'); }); test('appends after UID when the property is absent, so it lands in the right component', () => { const out = setProperty(ICS, 'LOCATION', 'Ramiro'); const lines = out.split('\r\n'); expect(lines[lines.indexOf('UID:abc') + 1]).toBe('LOCATION:Ramiro'); }); test('null removes the property', () => { expect(setProperty(ICS, 'RRULE', null)).not.toContain('RRULE'); }); test('escapes the TEXT separators', () => { const out = setProperty(ICS, 'SUMMARY', 'a, b; c'); expect(out).toContain(String.raw`SUMMARY:a\, b\; c`); expect(parseEvents(out)[0]!.summary).toBe('a, b; c'); }); // The regression. A folded property spans several lines; replacing only the first left the rest // behind, and they folded onto the NEW value — so each edit silently inherited the tail of the last. test('replaces the whole of a folded value, continuations included', () => { const folded = ICS.replace('SUMMARY:Before', 'SUMMARY:Before, and then quite a lot more text that\r\n was folded onto a second line'); const out = setProperty(folded, 'SUMMARY', 'After'); expect(parseEvents(out)[0]!.summary).toBe('After'); expect(out).not.toContain('folded onto a second line'); }); test('removing a folded property takes all of it', () => { const folded = ICS.replace('RRULE:FREQ=WEEKLY', 'RRULE:FREQ=WEEKLY\r\n ;COUNT=10'); expect(setProperty(folded, 'RRULE', null)).not.toContain('COUNT=10'); }); test('a long new value is folded, and reads back whole', () => { const long = 'y'.repeat(300); const out = setProperty(ICS, 'SUMMARY', long); expect(out.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true); expect(parseEvents(out)[0]!.summary).toBe(long); }); test('repeated edits do not accumulate', () => { let out = ICS; for (const v of ['one'.repeat(40), 'two'.repeat(40), 'three']) out = setProperty(out, 'SUMMARY', v); expect(parseEvents(out)[0]!.summary).toBe('three'); }); });