deepseek-harness/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx

335 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionSnapshot, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
import { ScheduleId } from '@deepseek-ai/dsh-schedule'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
formatScheduleFrequency,
formatScheduleLocalTime,
formatScheduleRelative,
orderScheduleRecords,
ScheduleCatalogAction,
type ScheduleCatalogActionProps,
} from '../src/client/ScheduleCatalogAction.tsx'
import { en, zh } from '../src/client/locales.ts'
const SESSION = 'schedule-session' as SessionId
const START = Date.parse('2026-08-25T12:00:00.000Z')
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(START)
document.documentElement.lang = 'en'
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
})
function record(
id: string,
kind: ScheduleRecord['kind'],
scheduledAt: number,
options: { prompt?: string; everySeconds?: number } = {},
): ScheduleRecord {
const common = {
id: ScheduleId(id),
kind,
prompt: options.prompt ?? id,
scheduledAt: new Date(scheduledAt).toISOString(),
}
if (kind === 'after') return { ...common, kind, afterSeconds: 30 }
if (kind === 'every') return { ...common, kind, everySeconds: options.everySeconds ?? 300 }
return { ...common, kind }
}
function sessionSnapshot(openState: SessionSnapshot['openState']): SessionSnapshot {
return {
sessionId: SESSION,
queue: [],
pendingSubmissions: [],
running: false,
subagent: null,
removed: false,
openState,
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: false,
lastAgentError: null,
promptAttempted: false,
awaitingFirstTurn: false,
}
}
function props(
records: readonly ScheduleRecord[] | undefined,
openState: SessionSnapshot['openState'] = 'open',
dictionary: typeof zh | typeof en = en,
): ScheduleCatalogActionProps {
const snapshot = sessionSnapshot(openState)
const useSession = <T,>(select: (value: SessionSnapshot) => T): T => select(snapshot)
const useProjection = ((key: string, select?: (value: unknown) => unknown) => {
const value = key === 'schedule' ? records : undefined
return select === undefined ? value : select(value)
}) as UseProjection
return {
sessionId: SESSION,
useSession,
useProjection,
t: makeTranslate(dictionary),
} as unknown as ScheduleCatalogActionProps
}
function prompts(): string[] {
return within(screen.getByRole('list', { name: en['list.aria'] }))
.getAllByRole('listitem')
.map(item => item.querySelector('[class*="prompt"]')?.textContent ?? '')
}
describe('ScheduleCatalogAction visibility', () => {
it('renders only for a successfully opened Session with a non-empty projection', () => {
const active = [record('active', 'after', START + 60_000)]
const view = render(<ScheduleCatalogAction {...props(undefined)} />)
expect(view.container.innerHTML).toBe('')
view.rerender(<ScheduleCatalogAction {...props([], 'open')} />)
expect(view.container.innerHTML).toBe('')
for (const state of ['cold', 'loading', 'error'] as const) {
view.rerender(<ScheduleCatalogAction {...props(active, state)} />)
expect(view.container.innerHTML).toBe('')
}
view.rerender(<ScheduleCatalogAction {...props(active)} />)
expect(screen.getByRole('button', { name: '1 reminder' })).toBeDefined()
})
it('closes and removes the trigger when the last live record disappears', () => {
const active = [record('active', 'after', START + 60_000)]
const view = render(<><button type="button">Neighbor</button><ScheduleCatalogAction {...props(active)} /></>)
const trigger = screen.getByRole('button', { name: '1 reminder' })
fireEvent.click(trigger)
trigger.focus()
expect(screen.getByRole('list', { name: en['list.aria'] })).toBeDefined()
view.rerender(<><button type="button">Neighbor</button><ScheduleCatalogAction {...props([])} /></>)
expect(screen.queryByRole('button', { name: '1 reminder' })).toBeNull()
expect(document.activeElement).toBe(document.body)
expect(screen.getByRole('button', { name: 'Neighbor' })).not.toBe(document.activeElement)
})
})
describe('ScheduleCatalogAction positioning', () => {
it('portals the catalog to the body and left-aligns it when space is available', () => {
const active = [record('active', 'after', START + 60_000)]
const view = render(<ScheduleCatalogAction {...props(active)} />)
const trigger = screen.getByRole('button', { name: '1 reminder' })
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
x: 240,
y: 20,
left: 240,
right: 320,
top: 20,
bottom: 48,
width: 80,
height: 28,
toJSON: () => ({}),
})
fireEvent.click(trigger)
const catalog = screen.getByRole('list', { name: en['list.aria'] })
expect(view.container.contains(catalog)).toBe(false)
expect(catalog.parentElement).toBe(document.body)
expect(catalog.style.left).toBe('240px')
expect(catalog.style.top).toBe('53px')
expect(catalog.style.visibility).toBe('')
})
})
describe('ScheduleCatalogAction rows', () => {
it('shows only prompt and the three derived metadata fields, with overdue records first', () => {
const rawPrompt = '<img src=x onerror=alert(1)> Keep the complete long reminder prompt visible without truncation.'
const overdue = record('hidden-id', 'after', START - 60_000, { prompt: rawPrompt })
const every = record('every-id', 'every', START + 300_000, { prompt: 'Check metrics', everySeconds: 300 })
const at = record('at-id', 'at', START + 3_600_000, { prompt: 'Join meeting' })
render(<ScheduleCatalogAction {...props([at, every, overdue])} />)
fireEvent.click(screen.getByRole('button'))
expect(prompts()).toEqual([rawPrompt, 'Check metrics', 'Join meeting'])
const rows = screen.getAllByRole('listitem')
expect(within(rows[0]!).getByText('Overdue', { exact: true })).toBeDefined()
expect(within(rows[1]!).getByText('Scheduled', { exact: true })).toBeDefined()
expect(rows[0]?.textContent).toContain('Once')
expect(rows[0]?.textContent).toContain('1 minute overdue')
expect(rows[1]?.textContent).toContain('Every 5 minutes')
expect(rows[1]?.textContent).toContain('in 5 minutes')
expect(rows[2]?.textContent).toContain('Once')
expect(rows[2]?.textContent).toContain('in 1 hour')
expect(rows[2]?.textContent).toContain(formatScheduleLocalTime(at.scheduledAt, 'en'))
expect(document.querySelector('img')).toBeNull()
const text = screen.getByRole('list').textContent ?? ''
expect(text).not.toContain('hidden-id')
expect(text).not.toContain(overdue.scheduledAt)
expect(text).not.toMatch(/Delete|Retry|Details/)
expect(within(screen.getByRole('list')).queryAllByRole('button')).toHaveLength(0)
expect(rows.every(row => row.tabIndex === -1)).toBe(true)
})
it('renders exact recurring units without rounding and localizes both dictionaries', () => {
const tEn = makeTranslate(en)
const tZh = makeTranslate(zh)
const samples = [
[86_400, 'Every 1 day', '1天一次'],
[172_800, 'Every 2 days', '2天一次'],
[3_600, 'Every 1 hour', '1小时一次'],
[7_200, 'Every 2 hours', '2小时一次'],
[300, 'Every 5 minutes', '5分钟一次'],
[301, 'Every 301 seconds', '301秒一次'],
] as const
for (const [seconds, english, chinese] of samples) {
const item = record(String(seconds), 'every', START + 1_000, { everySeconds: seconds })
expect(formatScheduleFrequency(item, tEn)).toBe(english)
expect(formatScheduleFrequency(item, tZh)).toBe(chinese)
}
expect(formatScheduleFrequency(record('once', 'at', START + 1_000), tZh)).toBe('单次')
expect(tZh('status.scheduled')).toBe('等待中')
expect(tZh('status.overdue')).toBe('已逾期')
})
it('formats absolute time with the active document locale instead of the runtime default', () => {
document.documentElement.lang = 'de-DE'
const item = record('localized', 'at', START + 3_600_000)
const localized = formatScheduleLocalTime(item.scheduledAt, 'de-DE')
expect(localized).not.toBe(formatScheduleLocalTime(item.scheduledAt))
render(<ScheduleCatalogAction {...props([item])} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('listitem').textContent).toContain(localized)
})
it('derives relative seconds, minutes, hours, days, and the exact due boundary', () => {
const t = makeTranslate(en)
expect(formatScheduleRelative(new Date(START).toISOString(), START, t)).toBe('Due now')
expect(formatScheduleRelative(new Date(START + 500).toISOString(), START, t)).toBe('in 1 second')
expect(formatScheduleRelative(new Date(START + 61_000).toISOString(), START, t)).toBe('in 2 minutes')
expect(formatScheduleRelative(new Date(START - 3_600_000).toISOString(), START, t)).toBe('1 hour overdue')
expect(formatScheduleRelative(new Date(START - 172_800_000).toISOString(), START, t)).toBe('2 days overdue')
})
it('keeps equal targets stable and updates overdue status as the browser clock advances', () => {
const first = record('first', 'at', START + 500)
const second = record('second', 'at', START + 500)
expect(orderScheduleRecords([first, second], START).map(item => item.id)).toEqual(['first', 'second'])
expect(orderScheduleRecords([
record('future', 'at', START + 1_000),
record('overdue', 'at', START - 1_000),
], START).map(item => item.id)).toEqual(['overdue', 'future'])
render(<ScheduleCatalogAction {...props([first, second])} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getAllByRole('listitem').every(row => (
within(row).queryByText('Scheduled', { exact: true }) !== null
))).toBe(true)
act(() => { vi.advanceTimersByTime(1_000) })
expect(screen.getAllByRole('listitem').every(row => (
within(row).queryByText('Overdue', { exact: true }) !== null
))).toBe(true)
})
})
describe('ScheduleCatalogAction dismissal', () => {
const active = [record('active', 'after', START + 60_000)]
it('closes on Escape inside the catalog and restores trigger focus', () => {
render(<ScheduleCatalogAction {...props(active)} />)
const trigger = screen.getByRole('button', { name: '1 reminder' })
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(screen.getByRole('list', { name: en['list.aria'] }), { key: 'Escape' })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
})
it('leaves the catalog open when Escape belongs to a sibling control', () => {
render(<><ScheduleCatalogAction {...props(active)} /><button type="button">Sibling</button></>)
const trigger = screen.getByRole('button', { name: '1 reminder' })
const sibling = screen.getByRole('button', { name: 'Sibling' })
fireEvent.click(trigger)
sibling.focus()
fireEvent.keyDown(sibling, { key: 'Escape' })
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(document.activeElement).toBe(sibling)
})
it('keeps a pointer press inside the portaled catalog open and dismisses outside', () => {
render(<ScheduleCatalogAction {...props(active)} />)
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
const catalog = screen.getByRole('list', { name: en['list.aria'] })
expect(catalog.parentElement).toBe(document.body)
fireEvent.pointerDown(catalog)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.pointerDown(document.body)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
})
it('uses native Tab navigation and Enter or Space activation', () => {
render(
<>
<button type="button">Before</button>
<ScheduleCatalogAction {...props(active)} />
<button type="button">After</button>
</>,
)
const trigger = screen.getByRole('button', { name: '1 reminder' })
const before = screen.getByRole('button', { name: 'Before' })
const after = screen.getByRole('button', { name: 'After' })
trigger.focus()
expect(trigger.tabIndex).toBe(0)
expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(true)
after.focus()
expect(document.activeElement).toBe(after)
trigger.focus()
expect(fireEvent.keyDown(trigger, { key: 'Tab', shiftKey: true })).toBe(true)
before.focus()
expect(document.activeElement).toBe(before)
trigger.focus()
expect(fireEvent.keyDown(trigger, { key: 'Enter' })).toBe(true)
fireEvent.click(trigger, { detail: 0 })
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(fireEvent.keyDown(trigger, { key: ' ', code: 'Space' })).toBe(true)
fireEvent.click(trigger, { detail: 0 })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
})
it('stops the clock while closed or unmounted and restarts it when reopened', () => {
const view = render(<ScheduleCatalogAction {...props(active)} />)
const trigger = screen.getByRole('button')
expect(vi.getTimerCount()).toBe(0)
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(vi.getTimerCount()).toBe(1)
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(vi.getTimerCount()).toBe(0)
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(vi.getTimerCount()).toBe(1)
view.unmount()
expect(vi.getTimerCount()).toBe(0)
})
})