- organize: seriesKey 移除 weekday, 解锁每3天等 DAILY 序列合并 - organize: fitSeries 放宽阈值(稀疏序列如 BUS LAW Workshop 合并) - organize: 新增 splitIntoChains, GCD=1 的交错多班次拆为多个系列 - useCalendar: 移除 dirty/history/undo/snapshot/markSaved 死代码 - HeaderBar: 移除撤销按钮与红点●, 下载兜底序列化当前状态 - 测试: 42 项(原32), 覆盖 DAILY/稀疏/交错拆分/两两和候选等场景 - 文档: README 精简, 新增 docs/organize-algorithm.md 与 auto-save-design.md - 保留移动端 CSS 适配
217 lines
8.2 KiB
JavaScript
217 lines
8.2 KiB
JavaScript
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
|
|
})
|
|
})
|