Files
dodo/frontend/src/CalendarPanel.test.ts
T
bboysoul 97c1bd4e87
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s
feat: auto-refresh calendar subscriptions
2026-09-21 15:36:09 +08:00

167 lines
14 KiB
TypeScript

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=>vi.isFakeTimers()?vi.advanceTimersByTimeAsync(0).then(()=>r(undefined)):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.useRealTimers();vi.unstubAllGlobals();vi.restoreAllMocks()})
describe('CalendarPanel',()=>{
it('focuses the current week and shows only the selected day events',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
const weekEvents=[
{...events[0],id:'today',title:'今天日程',starts_at:new Date(2026,8,20,9).toISOString(),ends_at:new Date(2026,8,20,10).toISOString()},
{...events[0],id:'monday',title:'周一日程',starts_at:new Date(2026,8,14,9).toISOString(),ends_at:new Date(2026,8,14,10).toISOString()},
]
const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:weekEvents,sources:[]}:subscriptions)))
const {host}=await mount(fetchMock)
expect(host.querySelectorAll('.calendar-week-day')).toHaveLength(7)
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20')
expect(host.textContent).toContain('今天日程');expect(host.textContent).not.toContain('周一日程')
host.querySelectorAll<HTMLButtonElement>('.calendar-week-day')[0].click();await nextTick()
expect(host.textContent).toContain('周一日程');expect(host.textContent).not.toContain('今天日程')
vi.useRealTimers()
})
it('moves one week at a time and can return to today',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
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 flush()
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('21')
host.querySelector<HTMLButtonElement>('[aria-label="回到今天"]')!.click();await flush()
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20')
vi.useRealTimers()
})
it('navigates to the exact next Monday across month and year boundaries',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,11,31,10))
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 flush()
expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.getAttribute('data-day')).toBe('2027-01-04')
expect(host.textContent).toContain('2027年1月')
const eventCalls=fetchMock.mock.calls.filter(([url])=>String(url).includes('calendar-events'))
const latest=new URL(String(eventCalls.at(-1)?.[0]),'http://localhost').searchParams
expect(latest.get('start')).toBe(new Date(2027,0,4).toISOString())
expect(latest.get('end')).toBe(new Date(2027,0,11).toISOString())
vi.useRealTimers()
})
it('loads subscriptions and the visible week then filters and opens event detail',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
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)
host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
host.querySelector<HTMLButtonElement>('[data-day="2026-09-22"]')!.click();await nextTick()
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()
vi.useRealTimers()
})
it('filters duplicate source names by source id',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
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.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()
host.querySelector<HTMLButtonElement>('[data-day="2026-09-22"]')!.click();await nextTick()
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()
vi.useRealTimers()
})
it('groups UTC events by the browser-local calendar day',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
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 boundaryDay=new Date(boundary[0].starts_at)
if(!host.querySelector(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)){host.querySelector<HTMLButtonElement>('[aria-label="下一周"]')!.click();await flush()}
host.querySelector<HTMLButtonElement>(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)!.click();await nextTick()
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).toContain(expected)
vi.useRealTimers()
})
it('keeps the newest week response when requests finish out of order',async()=>{
vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10))
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:'新一周',starts_at:'2026-09-21T02:00:00Z'}],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('旧一周')
vi.useRealTimers()
})
it('supports week 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('订阅地址无法访问')
})
})