import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, h, nextTick } from 'vue' import CountdownPanel from './CountdownPanel.vue' import { invalidateCountdownCache } from './lib/mvp-utils' const source = readFileSync('src/CountdownPanel.vue', 'utf8') const css = readFileSync('src/style.css', 'utf8') const cleanups: Array<() => void> = [] const countdown = { id:'c1', title:'发布日', event_date:'2026-09-20', display_date:'2026-09-20', kind:'countdown', repeat_rule:'none', icon:'', pinned:false, archived_at:null, days:4, calendar_mode:'solar', lunar_year:null, lunar_month:null, lunar_day:null, ignore_year:false, lunar_text:null, updated_at:'v1' } const countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 } function deferred() { let resolve!: (value:T)=>void; let reject!: (reason?:unknown)=>void; const promise = new Promise((yes,no)=>{ resolve=yes; reject=no }); return { promise, resolve, reject } } async function flush() { await Promise.resolve(); await new Promise((resolve)=>setTimeout(resolve, 0)); await Promise.resolve(); await nextTick() } async function mountWithFetch(fetchMock: ReturnType) { invalidateCountdownCache() vi.stubGlobal('fetch', fetchMock) const notices:string[]=[] const host=document.createElement('div'); document.body.append(host) const app=createApp(()=>h(CountdownPanel,{ onNotice:(message:string)=>notices.push(message) })); app.mount(host) const unmount=()=>{ app.unmount(); host.remove() }; cleanups.push(unmount) await flush(); return { host, notices, unmount } } function clickCountdown(host:HTMLElement, title:string) { const button=[...host.querySelectorAll('.countdown-focus,.countdown-row')].find((candidate)=>candidate.textContent?.includes(title)) expect(button).toBeTruthy(); button!.click() } function detailButton(label:string) { return [...document.querySelectorAll('.countdown-detail-sheet button')].find((button)=>button.textContent?.includes(label))! } function json(value: unknown) { return new Response(JSON.stringify(value), { status:200, headers:{ 'content-type':'application/json' } }) } afterEach(()=>{ cleanups.splice(0).forEach((cleanup)=>cleanup()); vi.unstubAllGlobals(); vi.restoreAllMocks() }) describe('countdown modal accessibility', () => { it('names the shared dialog contract and delegates focus and Escape handling', () => { expect(source).toContain('title-id="countdown-dialog-title"') expect(source).toContain('id="countdown-dialog-title"') expect(source).toContain('initial-focus="input[aria-label=\'倒数日名称\']"') expect(source).not.toContain('trapDialogFocus') expect(source).not.toContain('ref="titleInput"') expect(source).not.toContain('ref="detailCloseButton"') expect(source).toContain(':inert="open || Boolean(detailItem)"') }) it('uses one time-capsule focus and moves row actions into a detail sheet', () => { expect(source).toContain('class="countdown-timeline"') expect(source).toContain('class="countdown-row"') expect(source).toContain('class="countdown-focus"') expect(source).toContain('@click="openDetail(focusItem)"') expect(source).toContain('item.id !== focusItem.value?.id') expect(source).toContain('`kind-${item.kind}`') expect(source).toContain(':class="`kind-${focusItem.kind}`"') expect(source).toContain('panel-class="countdown-detail-sheet"') expect(source).not.toContain('ref="detailCloseButton"') expect(source).toContain('initial-focus="button[aria-label=\'关闭详情\']"') expect(source).not.toContain('trapDetailFocus') expect(source).not.toContain('class="countdown-actions"') }) it('does not show or edit countdown icons', () => { expect(source).not.toContain('countdown-icon') expect(source).not.toContain('form.icon') expect(source).not.toContain('item.icon') expect(source).not.toContain('focusItem.icon') expect(source).not.toContain('detailItem.icon') expect(source).not.toContain('图标与名称') }) it('keeps advanced creation fields collapsed behind more settings', () => { expect(source).toContain('class="countdown-advanced"') expect(source).toContain('更多设置') expect(source).toContain('@change="applyKindDefaults"') expect(source).toContain(':disabled="form.ignore_year"') }) it('shows cached countdowns immediately and shares one fresh/in-flight refresh', () => { expect(source).toContain('readCountdownCache()') expect(source).toContain('loadCountdownCache(fetchCountdowns, { force })') expect(source).toContain("request('/countdowns') as Promise") expect(source).toContain("request('/countdowns?archived=true') as Promise") expect(source).toContain('const generation = getCountdownCacheGeneration()') expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return') expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=') expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false') }) it('prevents duplicate submits and sends the edit precondition', () => { expect(source).toContain('if (busy.value) return') expect(source).toContain('payload.expected_updated_at=editingItem.value?.updated_at') expect(source).toContain(':disabled="busy"') }) it('keeps weekly and monthly recurrence available for lunar dates', () => { expect(source).toContain('') expect(source).toContain('') }) it('does not repeat the lunar label in the converted date line', () => { expect(source).toContain("return `${formatDate(item.display_date)}${suffix}`") expect(source).not.toContain("`${formatDate(item.display_date)} · ${item.lunar_text}${suffix}`") }) it('hides only the past group heading while keeping every group and its rows', () => { expect(source).toContain("{ key:'recent', title:'最近', items:recent }") expect(source).toContain("{ key:'later', title:'稍后', items:later }") expect(source).toContain("{ key:'past', title:'', items:past }") expect(source).toContain('
{{group.title}}') expect(source).toContain('