feat: 自动保存/移除撤销与红点, 间隔合并改进(DAILY/稀疏/交错拆分), 删死代码, 更新测试与文档

- 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 适配
This commit is contained in:
zikai
2026-07-22 06:32:47 +00:00
parent b49dbd46eb
commit 037b70deec
13 changed files with 617 additions and 137 deletions

View File

@@ -3,14 +3,30 @@ 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 } from '../src/lib/organize.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/weekday', () => {
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))
@@ -18,10 +34,17 @@ describe('seriesKey', () => {
it('separates events with different summary', () => {
const e1 = events[0] // ACC INFO SYS
// Find a BUS LAW event
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', () => {
@@ -38,9 +61,14 @@ describe('detectInterval', () => {
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', () => {
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)
@@ -51,32 +79,120 @@ describe('fitSeries', () => {
expect(result.exdates).toContain('2026-09-21')
})
it('rejects BUS LAW Workshop (too sparse: 3 events, 5 missing)', () => {
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)
expect(fitSeries(wsEvents)).toBeNull()
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 8', () => {
expect(organized).toHaveLength(8)
expect(stats.series).toBe(5)
expect(stats.flat).toBe(3)
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 5 recurring events with RRULE', () => {
it('produces 6 recurring events with RRULE', () => {
const recurring = organized.filter(e => e.rrule !== null)
expect(recurring).toHaveLength(5)
expect(recurring).toHaveLength(6)
})
it('produces 3 flat BUS LAW Workshop events', () => {
const flatWs = organized.filter(
e => e.summary === 'BUS LAW, Workshop' && e.rrule === null,
)
expect(flatWs).toHaveLength(3)
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)', () => {