feat: add iCal calendar subscriptions
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import CalendarPanel from './CalendarPanel.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:'2026-09-20T08:00:00Z', last_error:null, stale:false }]
|
||||
const events = [{ id:'e1', title:'发布会', starts_at:'2026-09-22T02:00:00Z', ends_at:'2026-09-22T03:00:00Z', all_day:false, description:'产品发布', location:'会议室', source_id:'s1', source_name:'工作', color:'#f15a29' }]
|
||||
const json = (value:unknown, status=200) => new Response(JSON.stringify(value), { status, headers:{'content-type':'application/json'} })
|
||||
async function flush(){ await Promise.resolve(); await new Promise(r=>setTimeout(r,0)); await nextTick() }
|
||||
async function mount(fetchMock:ReturnType<typeof vi.fn>){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} }
|
||||
afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.unstubAllGlobals();vi.restoreAllMocks()})
|
||||
|
||||
describe('CalendarPanel',()=>{
|
||||
it('loads subscriptions and the visible month then filters and opens event detail',async()=>{
|
||||
const fetchMock=vi.fn((url:string)=>url.includes('calendar-events')?Promise.resolve(json({events,sources:[{id:'s1',name:'工作',stale:false}]})):Promise.resolve(json(subscriptions)))
|
||||
const {host}=await mount(fetchMock)
|
||||
const eventsUrl=String(fetchMock.mock.calls.find(([url])=>String(url).includes('calendar-events'))?.[0])
|
||||
const params=new URL(eventsUrl,'http://localhost').searchParams
|
||||
expect(params.get('start')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||
expect(params.get('end')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||
expect(host.textContent).toContain('发布会');expect(host.textContent).toContain('工作')
|
||||
host.querySelector<HTMLButtonElement>('[data-event-id="e1"]')!.click();await nextTick()
|
||||
expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布')
|
||||
host.querySelector<HTMLInputElement>('input[aria-label="筛选工作"]')!.click();await nextTick()
|
||||
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
||||
})
|
||||
it('filters duplicate source names by source id',async()=>{
|
||||
const duplicateSubscriptions=[subscriptions[0],{...subscriptions[0],id:'s2',url:'https://example.com/personal.ics',color:'#334455'}]
|
||||
const duplicateEvents=[events[0],{...events[0],id:'e2',title:'私人日程',source_id:'s2',color:'#334455'}]
|
||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:duplicateEvents,sources:[]}:duplicateSubscriptions)))
|
||||
const {host}=await mount(fetchMock)
|
||||
host.querySelectorAll<HTMLInputElement>('input[aria-label="筛选工作"]')[0].click();await nextTick()
|
||||
expect(host.querySelector('[data-event-id="e1"]')).toBeNull()
|
||||
expect(host.querySelector('[data-event-id="e2"]')).not.toBeNull()
|
||||
})
|
||||
it('groups UTC events by the browser-local calendar day',async()=>{
|
||||
const boundary=[{...events[0],id:'boundary',starts_at:'2026-09-21T23:30:00Z',ends_at:'2026-09-22T00:30:00Z'}]
|
||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions)))
|
||||
const {host}=await mount(fetchMock)
|
||||
const expected=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(boundary[0].starts_at))
|
||||
expect(host.querySelector('.calendar-agenda h2')?.textContent).toBe(expected)
|
||||
})
|
||||
it('keeps the newest month response when requests finish out of order',async()=>{
|
||||
const pending:Array<{url:string;resolve:(response:Response)=>void}>=[]
|
||||
const fetchMock=vi.fn((url:string)=>String(url).includes('calendar-events')?new Promise<Response>(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions)))
|
||||
const {host}=await mount(fetchMock)
|
||||
expect(pending).toHaveLength(1)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await nextTick()
|
||||
expect(pending).toHaveLength(2)
|
||||
pending[1].resolve(json({events:[{...events[0],id:'new',title:'新月份'}],sources:[]}));await flush()
|
||||
pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧月份'}],sources:[]}));await flush()
|
||||
expect(host.textContent).toContain('新月份')
|
||||
expect(host.textContent).not.toContain('旧月份')
|
||||
})
|
||||
it('supports month navigation and today',async()=>{
|
||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
|
||||
const {host}=await mount(fetchMock);const before=fetchMock.mock.calls.length
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="下个月"]')!.click();await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2)
|
||||
})
|
||||
it('closes the subscription form without submitting it',async()=>{
|
||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions)))
|
||||
const {host}=await mount(fetchMock)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="添加日历订阅"]')!.click();await nextTick()
|
||||
const name=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!,url=document.querySelector<HTMLInputElement>('input[aria-label="订阅地址"]')!
|
||||
name.value='私人';name.dispatchEvent(new Event('input'));url.value='https://example.com/a.ics';url.dispatchEvent(new Event('input'));await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="关闭订阅表单"]')!.click();await flush()
|
||||
const calls=fetchMock.mock.calls as unknown as Array<[string,RequestInit?]>
|
||||
expect(calls.some(([,options])=>options?.method==='POST')).toBe(false)
|
||||
})
|
||||
it('creates, edits, toggles, refreshes and deletes a source with confirmation',async()=>{
|
||||
const calls:Array<[string,RequestInit|undefined]>=[]
|
||||
const fetchMock=vi.fn((url:string,options?:RequestInit)=>{calls.push([url,options]);if(options?.method==='DELETE')return Promise.resolve(new Response(null,{status:204}));if(options?.method)return Promise.resolve(json(subscriptions[0]));return Promise.resolve(json(url.includes('calendar-events')?{events,sources:[{id:'s1',name:'工作',stale:false}]}:subscriptions))})
|
||||
const {host}=await mount(fetchMock)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="添加日历订阅"]')!.click();await nextTick()
|
||||
const name=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!,url=document.querySelector<HTMLInputElement>('input[aria-label="订阅地址"]')!;name.value='私人';name.dispatchEvent(new Event('input'));url.value='https://example.com/a.ics';url.dispatchEvent(new Event('input'));await nextTick();document.querySelector<HTMLButtonElement>('.calendar-subscription-form button[type="submit"]')!.click();await flush()
|
||||
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions')&&o?.method==='POST')).toBe(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="编辑工作"]')!.click();await nextTick()
|
||||
const editedName=document.querySelector<HTMLInputElement>('input[aria-label="订阅名称"]')!;editedName.value='工作日历';editedName.dispatchEvent(new Event('input'));await nextTick();document.querySelector<HTMLButtonElement>('.calendar-subscription-form button[type="submit"]')!.click();await flush()
|
||||
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='PATCH'&&String(o.body).includes('工作日历'))).toBe(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
|
||||
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1/refresh')&&o?.method==='POST')).toBe(true)
|
||||
document.querySelector<HTMLInputElement>('[aria-label="启用工作"]')!.click();await flush()
|
||||
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='PATCH')).toBe(true)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="删除工作"]')!.click();await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('.app-dialog .danger-button')!.click();await flush()
|
||||
expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='DELETE')).toBe(true)
|
||||
})
|
||||
it('clears an earlier action error after a successful retry',async()=>{
|
||||
let failRefresh=true
|
||||
const fetchMock=vi.fn((url:string,options?:RequestInit)=>{
|
||||
if(options?.method==='POST'&&String(url).endsWith('/refresh')&&failRefresh){failRefresh=false;return Promise.resolve(json({detail:'上游不可用'},502))}
|
||||
if(options?.method)return Promise.resolve(json(subscriptions[0]))
|
||||
return Promise.resolve(json(String(url).includes('calendar-events')?{events:[],sources:[]}:subscriptions))
|
||||
})
|
||||
const {host}=await mount(fetchMock);host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
|
||||
expect(host.querySelector('.inline-error')?.textContent).toContain('上游不可用')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="刷新工作"]')!.click();await flush()
|
||||
expect(host.querySelector('.inline-error')).toBeNull()
|
||||
})
|
||||
it('shows source-specific refresh errors',async()=>{
|
||||
const failed=[{...subscriptions[0],last_error:'订阅地址无法访问'}]
|
||||
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:failed)))
|
||||
const {host}=await mount(fetchMock);host.querySelector<HTMLButtonElement>('[aria-label="管理日历源"]')!.click();await nextTick()
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain('订阅地址无法访问')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user