import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { parseICS } from '../src/lib/ical-io.js' import { organize, seriesKey, detectInterval, fitSeries, splitIntoChains } from '../src/lib/organize.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8') const { events } = parseICS(GOLDEN) // 构造最小 EventModel 用于合成用例 function mk(dtstart, summary, loc = 'L', st = '09:00', et = '10:00') { return { uid: 'uid-' + dtstart + '-' + summary, summary, location: loc, description: '', dtstartDate: dtstart, dtstartTime: st, dtendTime: et, tzid: 'UTC', rrule: null, exdates: [], _raw: { dtstamp: '20260711T085008Z' }, } } // 由相对天偏移生成 ISO 日期 (基准 2026-01-05 = 周一) function iso(off) { const d = new Date(2026, 0, 5) d.setDate(d.getDate() + off) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } describe('seriesKey', () => { it('groups events with same summary/location/time/duration', () => { const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00 const e2 = events[1] // same series, next week expect(seriesKey(e1)).toBe(seriesKey(e2)) }) it('separates events with different summary', () => { const e1 = events[0] // ACC INFO SYS const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial')) expect(seriesKey(e1)).not.toBe(seriesKey(eBus)) }) it('groups DAILY-series events that fall on different weekdays', () => { // 每 3 天 -> 落在 周一/周四/周日/周三/周六, 星期各不同 const every3 = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med')) const keys = every3.map(seriesKey) // 键不再含星期, 故全部相同 expect(new Set(keys).size).toBe(1) }) }) describe('detectInterval', () => { it('returns 7 for weekly dates', () => { const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-17'] expect(detectInterval(dates)).toBe(7) }) it('returns null for single date', () => { expect(detectInterval(['2026-07-27'])).toBeNull() }) it('returns 7 for dates with a skip (gaps 7,7,14,7)', () => { const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31'] expect(detectInterval(dates)).toBe(7) }) it('returns 3 for every-3-day dates', () => { const dates = ['2026-01-05', '2026-01-08', '2026-01-11', '2026-01-14'] expect(detectInterval(dates)).toBe(3) }) }) describe('fitSeries (single GCD grid)', () => { it('fits ACC INFO SYS series (12 events, 1 skip)', () => { const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial') const result = fitSeries(accEvents) expect(result).not.toBeNull() expect(result.rrule.freq).toBe('WEEKLY') expect(result.rrule.interval).toBe(1) expect(result.rrule.byday).toBe('MO') expect(result.exdates).toContain('2026-09-21') }) it('merges sparse BUS LAW Workshop (gaps 21,28 -> gcd 7) into WEEKLY + exdates', () => { const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop') expect(wsEvents).toHaveLength(3) const result = fitSeries(wsEvents) expect(result).not.toBeNull() expect(result.rrule.freq).toBe('WEEKLY') expect(result.rrule.interval).toBe(1) expect(result.rrule.byday).toBe('WE') expect(result.rrule.untilDate).toBe('2026-09-30') // 8 周跨度内 3 次出现 -> 5 次缺席 expect(result.exdates).toHaveLength(5) expect(result.exdates).toContain('2026-08-19') expect(result.exdates).toContain('2026-09-23') }) it('merges every-3-day series into DAILY;INTERVAL=3', () => { const evs = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med')) const result = fitSeries(evs) expect(result).not.toBeNull() expect(result.rrule.freq).toBe('DAILY') expect(result.rrule.interval).toBe(3) expect(result.rrule.byday).toBeNull() expect(result.exdates).toEqual([]) }) it('merges biweekly (14-day) series into WEEKLY;INTERVAL=2', () => { const evs = [0, 14, 28, 42, 56].map(off => mk(iso(off), 'Bi')) const result = fitSeries(evs) expect(result).not.toBeNull() expect(result.rrule.freq).toBe('WEEKLY') expect(result.rrule.interval).toBe(2) expect(result.rrule.byday).toBe('MO') }) it('rejects interleaved weekday classes (gcd=1)', () => { // 周一 + 周二, 同名同时同地, 排序后 gap 1,6,1,6,1,6 -> gcd 1 const mon = [0, 7, 14].map(off => mk(iso(off), 'Math', 'R1')) const tue = [1, 8, 15].map(off => mk(iso(off), 'Math', 'R1')) expect(fitSeries([...mon, ...tue])).toBeNull() }) }) describe('splitIntoChains', () => { it('splits interleaved Mon+Tue classes into 2 weekly chains', () => { const dates = [0, 1, 7, 8, 14, 15].map(iso) const chains = splitIntoChains(dates) expect(chains).toHaveLength(2) expect(chains[0]).toEqual([iso(0), iso(7), iso(14)]) expect(chains[1]).toEqual([iso(1), iso(8), iso(15)]) }) it('splits user example (gaps 2,5,2,5,2,12,2,5,2,5) into 2 weekly chains', () => { // 两个交错周序列, 中间各暂停一周 const dates = [0, 2, 7, 9, 14, 16, 28, 30, 35, 37, 42].map(iso) const chains = splitIntoChains(dates) expect(chains).toHaveLength(2) // 链 A: 0,7,14,28,35,42 (跳过 21) expect(chains[0]).toEqual([iso(0), iso(7), iso(14), iso(28), iso(35), iso(42)]) // 链 B: 2,9,16,30,37 (跳过 23) expect(chains[1]).toEqual([iso(2), iso(9), iso(16), iso(30), iso(37)]) }) }) describe('organize', () => { it('merges every-3-day DAILY series (end-to-end via organize)', () => { const evs = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med')) const { events: out, stats } = organize(evs) expect(out).toHaveLength(1) expect(stats.series).toBe(1) expect(out[0].rrule.freq).toBe('DAILY') expect(out[0].rrule.interval).toBe(3) }) it('splits interleaved Mon+Tue into 2 separate WEEKLY events', () => { const mon = [0, 7, 14].map(off => mk(iso(off), 'Math', 'R1')) const tue = [1, 8, 15].map(off => mk(iso(off), 'Math', 'R1')) const { events: out, stats } = organize([...mon, ...tue]) expect(out).toHaveLength(2) expect(stats.series).toBe(2) expect(stats.flat).toBe(0) const bydays = out.map(e => e.rrule.byday).sort() expect(bydays).toEqual(['MO', 'TU']) }) it('does not merge 2 consecutive same-name events (no pairwise-sum candidate)', () => { const evs = [0, 1].map(off => mk(iso(off), 'OneOff')) const { events: out, stats } = organize(evs) expect(stats.series).toBe(0) expect(stats.flat).toBe(2) }) }) describe('organize (golden test against org.ics)', () => { const { events: organized, stats } = organize(events) it('collapses 61 events into 6 (6 series, 0 flat)', () => { expect(organized).toHaveLength(6) expect(stats.series).toBe(6) expect(stats.flat).toBe(0) }) it('produces 6 recurring events with RRULE', () => { const recurring = organized.filter(e => e.rrule !== null) expect(recurring).toHaveLength(6) }) it('merges BUS LAW Workshop into 1 recurring event with 5 exdates', () => { const ws = organized.filter(e => e.summary === 'BUS LAW, Workshop') expect(ws).toHaveLength(1) expect(ws[0].rrule).not.toBeNull() expect(ws[0].rrule.freq).toBe('WEEKLY') expect(ws[0].exdates).toHaveLength(5) expect(ws[0].exdates).toContain('2026-08-19') expect(ws[0].exdates).toContain('2026-09-23') }) it('ACC INFO SYS series has 1 exdate (Sep 21)', () => { const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial') expect(acc.rrule).not.toBeNull() expect(acc.exdates).toEqual(['2026-09-21']) }) it('BUS LAW Tutorial series has 2 exdates', () => { const bl = organized.find(e => e.summary === 'BUS LAW, Tutorial') expect(bl.rrule).not.toBeNull() expect(bl.exdates).toHaveLength(2) expect(bl.exdates).toContain('2026-08-31') expect(bl.exdates).toContain('2026-09-21') }) it('preserves original UID of base event', () => { const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial') expect(acc.uid).toBe('uid0') // first event in series }) })