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,compactLayout=true){ vi.stubGlobal('fetch',fetchMock); const shell=document.createElement('div');shell.className='shell';const host=document.createElement('div');shell.append(host);document.body.append(shell);const notices:string[]=[];const errors:unknown[]=[];const app=createApp(()=>h(CalendarPanel,{compactLayout,onNotice:(v:string)=>notices.push(v)}));app.config.errorHandler=error=>errors.push(error);app.mount(host);cleanups.push(()=>{app.unmount();shell.remove()});await flush();return {host,shell,notices,errors} } 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('.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('[aria-label="下一周"]')!.click();await flush() expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('21') host.querySelector('[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('[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('[aria-label="下一周"]')!.click();await flush() host.querySelector('[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('[data-event-id="e1"]')!.click();await nextTick() expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布') expect(document.querySelector('.calendar-event-detail__meta')?.textContent).toContain('9月22日周二') expect(document.querySelector('.calendar-event-detail__meta')?.textContent).toContain('工作') expect(document.querySelector('.calendar-event-detail__content')?.textContent).toContain('产品发布') expect(document.querySelector('.calendar-event-detail dl')).toBeNull() host.querySelector('input[aria-label="筛选工作"]')!.click();await nextTick() expect(host.querySelector('[data-event-id="e1"]')).toBeNull() vi.useRealTimers() }) it('opens and closes desktop event detail without teleport patch errors',async()=>{ vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,22,10)) const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events,sources:[]}:subscriptions))) const {host,shell,errors}=await mount(fetchMock,false) host.querySelector('[data-event-id="e1"]')!.click();await nextTick() expect(shell.querySelector('.calendar-event-detail')).not.toBeNull() shell.querySelector('[aria-label="关闭日程详情"]')!.click();await nextTick() expect(shell.querySelector('.calendar-event-detail')).toBeNull() expect(errors).toEqual([]) 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('[aria-label="下一周"]')!.click();await flush() host.querySelector('[data-day="2026-09-22"]')!.click();await nextTick() host.querySelectorAll('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('[aria-label="下一周"]')!.click();await flush()} host.querySelector(`[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(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions))) const {host}=await mount(fetchMock) expect(pending).toHaveLength(1) host.querySelector('[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('[aria-label="下一周"]')!.click();await flush() host.querySelector('[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('[aria-label="管理日历源"]')!.click();await nextTick() document.querySelector('[aria-label="添加日历订阅"]')!.click();await nextTick() const name=document.querySelector('input[aria-label="订阅名称"]')!,url=document.querySelector('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('[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('[aria-label="管理日历源"]')!.click();await nextTick() document.querySelector('[aria-label="添加日历订阅"]')!.click();await nextTick() const name=document.querySelector('input[aria-label="订阅名称"]')!,url=document.querySelector('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('.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('[aria-label="管理日历源"]')!.click();await nextTick() document.querySelector('[aria-label="编辑工作"]')!.click();await nextTick() const editedName=document.querySelector('input[aria-label="订阅名称"]')!;editedName.value='工作日历';editedName.dispatchEvent(new Event('input'));await nextTick();document.querySelector('.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('[aria-label="管理日历源"]')!.click();await nextTick() document.querySelector('[aria-label="刷新工作"]')!.click();await flush() expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1/refresh')&&o?.method==='POST')).toBe(true) document.querySelector('[aria-label="启用工作"]')!.click();await flush() expect(calls.some(([u,o])=>u.endsWith('/calendar-subscriptions/s1')&&o?.method==='PATCH')).toBe(true) document.querySelector('[aria-label="删除工作"]')!.click();await nextTick() document.querySelector('.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('[aria-label="管理日历源"]')!.click();await nextTick() document.querySelector('[aria-label="刷新工作"]')!.click();await flush() expect(host.querySelector('.inline-error')?.textContent).toContain('上游不可用') document.querySelector('[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('[aria-label="管理日历源"]')!.click();await nextTick() expect(document.querySelector('[role="alert"]')?.textContent).toContain('订阅地址无法访问') }) })