243 lines
14 KiB
TypeScript
243 lines
14 KiB
TypeScript
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<T>() { let resolve!: (value:T)=>void; let reject!: (reason?:unknown)=>void; const promise = new Promise<T>((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<typeof vi.fn>) {
|
|
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<HTMLButtonElement>('.countdown-focus,.countdown-row')].find((candidate)=>candidate.textContent?.includes(title))
|
|
expect(button).toBeTruthy(); button!.click()
|
|
}
|
|
function detailButton(label:string) {
|
|
return [...document.querySelectorAll<HTMLButtonElement>('.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('<span>更多设置</span>')
|
|
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<Countdown>()')
|
|
expect(source).toContain('loadCountdownCache(fetchCountdowns, { force })')
|
|
expect(source).toContain("request('/countdowns') as Promise<Countdown[]>")
|
|
expect(source).toContain("request('/countdowns?archived=true') as Promise<Countdown[]>")
|
|
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('<option value="weekly">每周</option>')
|
|
expect(source).toContain('<option value="monthly">每月</option>')
|
|
})
|
|
|
|
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('<section v-for="group in countdownGroups"')
|
|
expect(source).toContain('<h3 v-if="group.title">{{group.title}}</h3>')
|
|
expect(source).toContain('<button v-for="item in group.items"')
|
|
expect(source).not.toContain('<h3>{{group.title}}</h3>')
|
|
})
|
|
|
|
it('keeps active rows compact and archive controls accessible', () => {
|
|
expect(source).not.toContain('class="countdown-hero"')
|
|
expect(source).toContain('class="countdown-summary"')
|
|
expect(source).not.toContain('<template v-if="secondaryDate(item)">')
|
|
expect(source).not.toContain('visibleBadges(item)')
|
|
expect(source).not.toContain('class="pinned-icon"')
|
|
expect(source).toContain(':aria-expanded="showArchived"')
|
|
expect(source).toContain('aria-controls="archived-countdowns"')
|
|
expect(source).toContain('id="archived-countdowns"')
|
|
})
|
|
|
|
it('keeps archived countdowns as one surface with separator-only mobile rows', () => {
|
|
expect(css).toMatch(/\.archived-countdowns\.archived-countdowns\{[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\);[^}]*border-radius:var\(--radius-list\);[^}]*box-shadow:none/)
|
|
expect(css).toMatch(/\.archived-countdowns\.archived-countdowns article\{[^}]*border:0;[^}]*border-radius:0;[^}]*box-shadow:none/)
|
|
expect(css).toContain('.archived-countdowns.archived-countdowns article+article{border-top:1px solid var(--border-cream)}')
|
|
})
|
|
|
|
it('keeps the empty state directly actionable', () => {
|
|
expect(source).toContain('添加第一个重要日子')
|
|
expect(source).toContain('@click="openFromEmpty"')
|
|
})
|
|
|
|
it('sends only one pin request on a rapid double click and disables all detail writes', async () => {
|
|
const pin = deferred<Response>()
|
|
const fetchMock = vi.fn((url: string, options?: RequestInit) => {
|
|
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
|
throw new Error(`unexpected ${url} ${options?.method}`)
|
|
})
|
|
const { host } = await mountWithFetch(fetchMock)
|
|
host.querySelector<HTMLButtonElement>('.countdown-focus')!.click(); await nextTick()
|
|
const pinButton = [...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].find((button)=>button.textContent?.includes('置顶'))!
|
|
pinButton.click(); pinButton.click(); await nextTick()
|
|
expect(fetchMock.mock.calls.filter(([url])=>String(url).endsWith('/countdowns/c1/pin'))).toHaveLength(1)
|
|
expect([...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].every((button)=>button.disabled)).toBe(true)
|
|
pin.resolve(json({})); await flush()
|
|
})
|
|
|
|
it('ignores a stale successful detail write after close and opening another countdown', async () => {
|
|
const pin = deferred<Response>()
|
|
const fetchMock = vi.fn((url: string) => {
|
|
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
|
throw new Error(`unexpected ${url}`)
|
|
})
|
|
const { host, notices } = await mountWithFetch(fetchMock)
|
|
clickCountdown(host, '发布日'); await nextTick()
|
|
detailButton('置顶').click(); await nextTick()
|
|
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
|
clickCountdown(host, '旅行日'); await nextTick()
|
|
|
|
pin.resolve(json({})); await flush()
|
|
|
|
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
|
expect(notices).toEqual([])
|
|
expect(detailButton('置顶').disabled).toBe(false)
|
|
})
|
|
|
|
it('ignores a stale failed detail write without polluting the new detail or unlocking its operation', async () => {
|
|
const oldPin = deferred<Response>()
|
|
const newPin = deferred<Response>()
|
|
const fetchMock = vi.fn((url: string) => {
|
|
if (url.endsWith('/countdowns/c1/pin')) return oldPin.promise
|
|
if (url.endsWith('/countdowns/c2/pin')) return newPin.promise
|
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
|
throw new Error(`unexpected ${url}`)
|
|
})
|
|
const { host, notices } = await mountWithFetch(fetchMock)
|
|
clickCountdown(host, '发布日'); await nextTick()
|
|
detailButton('置顶').click(); await nextTick()
|
|
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
|
clickCountdown(host, '旅行日'); await nextTick()
|
|
detailButton('置顶').click(); await nextTick()
|
|
|
|
oldPin.reject(new Error('旧请求失败')); await flush()
|
|
|
|
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
|
expect(host.querySelector('.inline-error')?.textContent ?? '').not.toContain('旧请求失败')
|
|
expect(notices).toEqual([])
|
|
expect(detailButton('置顶').disabled).toBe(true)
|
|
newPin.resolve(json({})); await flush()
|
|
})
|
|
|
|
it('pins and unpins from detail without duplicate requests', async () => {
|
|
const mutation = deferred<Response>()
|
|
const pinned = { ...countdown, pinned: true }
|
|
const fetchMock = vi.fn((url: string, options?: RequestInit) => {
|
|
if (url.endsWith('/countdowns/c1/pin') && options?.method === 'DELETE') return mutation.promise
|
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([pinned]))
|
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
|
throw new Error(`unexpected ${url}`)
|
|
})
|
|
const { host, notices } = await mountWithFetch(fetchMock)
|
|
clickCountdown(host, '发布日'); await nextTick()
|
|
const button = detailButton('取消置顶')
|
|
button.click(); button.click()
|
|
await nextTick()
|
|
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/countdowns/c1/pin'))).toHaveLength(1)
|
|
expect(button.disabled).toBe(true)
|
|
mutation.resolve(json({ ...pinned, pinned: false })); await flush()
|
|
expect(notices).toEqual(['已取消置顶'])
|
|
expect(document.querySelector('.countdown-detail-sheet')).toBeNull()
|
|
})
|
|
|
|
it('keeps normal current-detail pin flow working', async () => {
|
|
const pin = deferred<Response>()
|
|
const fetchMock = vi.fn((url: string) => {
|
|
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
|
throw new Error(`unexpected ${url}`)
|
|
})
|
|
const { host, notices } = await mountWithFetch(fetchMock)
|
|
clickCountdown(host, '发布日'); await nextTick()
|
|
detailButton('置顶').click(); pin.resolve(json({})); await flush()
|
|
expect(document.querySelector('.countdown-detail-sheet')).toBeNull()
|
|
expect(host.querySelector('.countdown-view')?.classList.contains('loading')).toBe(false)
|
|
expect(notices).toEqual(['已置顶'])
|
|
})
|
|
|
|
it('guards mutation commits and cleanup with the captured detail context', () => {
|
|
expect(source).toContain('const operationGeneration = ref(0)')
|
|
expect(source).toContain('const detailGeneration = ref(0)')
|
|
expect(source).toContain('context.detailGeneration === detailGeneration.value')
|
|
expect(source).toContain("context.detailId === (detailItem.value?.id ?? null)")
|
|
expect(source).toContain('if (currentContext(context)) busy.value=false')
|
|
})
|
|
})
|