mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
test(automations): verify scroll-to-focus path in virtualized runs
- Implement scrollToIndex in virtualizer stub to move viewport window - Optimize row-size estimation to use predicate instead of labels - Test validates keyboard navigation scrolls rows into view before focus
This commit is contained in:
@@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import { AutomationRunHistory } from './AutomationRunHistory'
|
||||
import { WORKSPACE_ID, makeRun, makeRunUsage, makeWorktree } from './automations-page-fixtures'
|
||||
import { VIRTUALIZER_STUB_WINDOW_SIZE } from './virtualizer-test-stub'
|
||||
|
||||
vi.mock('@tanstack/react-virtual', async () => {
|
||||
const { createVirtualizerStub } = await import('./virtualizer-test-stub')
|
||||
@@ -163,6 +164,49 @@ describe('AutomationRunHistory virtualization', () => {
|
||||
// The count above the table still speaks for the whole history, not the window.
|
||||
expect(container.textContent).toContain('5000 runs')
|
||||
})
|
||||
|
||||
it('scrolls a selected row below the fold into the window and then focuses it', async () => {
|
||||
const runs = Array.from({ length: VIRTUALIZER_STUB_WINDOW_SIZE * 2 }, (_, index) =>
|
||||
makeRun({ id: `run-${index}`, scheduledFor: FIRST + index })
|
||||
)
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AutomationRunHistory
|
||||
runs={runs}
|
||||
automationId="a-1"
|
||||
worktreeMap={new Map()}
|
||||
onOpenRun={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const belowFold = `run-${VIRTUALIZER_STUB_WINDOW_SIZE}`
|
||||
expect(container.querySelector(`[data-automation-run-id="${belowFold}"]`)).toBeNull()
|
||||
|
||||
// Selection starts on the first row, so this many moves lands one row past the
|
||||
// window — the case where focus has to wait for the scroll to mount the row.
|
||||
for (let move = 0; move < VIRTUALIZER_STUB_WINDOW_SIZE; move += 1) {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const selected = container.querySelector<HTMLButtonElement>(
|
||||
`[data-automation-run-id="${belowFold}"]`
|
||||
)
|
||||
expect(selected?.getAttribute('data-current')).toBe('true')
|
||||
expect(document.activeElement).toBe(selected)
|
||||
// The window moved rather than grew: the row it scrolled past is unmounted.
|
||||
expect(container.querySelector('[data-automation-run-id="run-0"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutomationRunHistory keyboard navigation', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
formatAutomationTokens,
|
||||
getAutomationUsageStatusLabel
|
||||
} from './automation-usage-model'
|
||||
import { automationRunOccurrenceLabel } from './automation-run-occurrences'
|
||||
import { automationRunOccurrenceLabel, isAutomationRunFolded } from './automation-run-occurrences'
|
||||
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
|
||||
import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice'
|
||||
import type { AutomationActionNotice } from './automation-row-action-dispatch'
|
||||
@@ -91,7 +91,9 @@ export function AutomationRunHistory({
|
||||
const estimateRunRowSize = useCallback(
|
||||
(index: number): number => {
|
||||
const run = runs[index]
|
||||
return run && automationRunOccurrenceLabel(run)
|
||||
// The predicate, not the label: estimateSize is asked for unmounted indexes too,
|
||||
// and building the label there would translate and format a date per run.
|
||||
return run && isAutomationRunFolded(run)
|
||||
? RUN_ROW_HEIGHT_PX + RUN_ROW_OCCURRENCE_LINE_PX
|
||||
: RUN_ROW_HEIGHT_PX
|
||||
},
|
||||
|
||||
@@ -13,12 +13,17 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
type AutomationRunOccurrences = Pick<AutomationRun, 'occurrenceCount' | 'lastOccurrenceAt'>
|
||||
|
||||
/** The label's condition without its cost; row-size estimation asks it per history item. */
|
||||
export function isAutomationRunFolded(run: AutomationRunOccurrences): boolean {
|
||||
return (run.occurrenceCount ?? 1) > 1
|
||||
}
|
||||
|
||||
/** Null for the single-occurrence rows, which is every row written before folding. */
|
||||
export function automationRunOccurrenceLabel(run: AutomationRunOccurrences): string | null {
|
||||
const count = run.occurrenceCount ?? 1
|
||||
if (count <= 1) {
|
||||
if (!isAutomationRunFolded(run)) {
|
||||
return null
|
||||
}
|
||||
const count = run.occurrenceCount ?? 1
|
||||
// Not named `count`: i18next reserves it for plural selection, which would send
|
||||
// these keys looking for `_one`/`_other` variants the catalog does not carry.
|
||||
// The label only renders above 1, so the plural is always right.
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
* that measurement straight to the virtualizer — so the real `useVirtualizer`
|
||||
* renders no rows at all under test. This stub renders a bounded window instead,
|
||||
* which is what the virtualization assertions are actually about.
|
||||
*
|
||||
* The window starts at index 0 and only moves when `scrollToIndex` names an index
|
||||
* outside it, so a row below the fold stays unmounted until the component scrolls
|
||||
* to it — the sequence a deferred-focus path depends on.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export const VIRTUALIZER_STUB_WINDOW_SIZE = 21
|
||||
|
||||
type VirtualizerStubOptions = {
|
||||
count: number
|
||||
estimateSize: (index: number) => number
|
||||
@@ -19,9 +27,10 @@ type VirtualizerStub = {
|
||||
}
|
||||
|
||||
export function createVirtualizerStub(
|
||||
windowSize = 21
|
||||
windowSize = VIRTUALIZER_STUB_WINDOW_SIZE
|
||||
): (options: VirtualizerStubOptions) => VirtualizerStub {
|
||||
return ({ count, estimateSize, getItemKey }) => {
|
||||
const [windowStart, setWindowStart] = useState(0)
|
||||
const sizes = Array.from({ length: count }, (_, index) => estimateSize(index))
|
||||
let offset = 0
|
||||
const starts = sizes.map((size) => {
|
||||
@@ -32,14 +41,32 @@ export function createVirtualizerStub(
|
||||
return {
|
||||
getTotalSize: () => sizes.reduce((total, size) => total + size, 0),
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: Math.min(count, windowSize) }, (_, index) => ({
|
||||
index,
|
||||
key: getItemKey?.(index) ?? index,
|
||||
start: starts[index] ?? 0,
|
||||
size: sizes[index] ?? 0
|
||||
})),
|
||||
Array.from(
|
||||
{ length: Math.max(0, Math.min(windowSize, count - windowStart)) },
|
||||
(_, position) => {
|
||||
const index = windowStart + position
|
||||
return {
|
||||
index,
|
||||
key: getItemKey?.(index) ?? index,
|
||||
start: starts[index] ?? 0,
|
||||
size: sizes[index] ?? 0
|
||||
}
|
||||
}
|
||||
),
|
||||
measureElement: () => undefined,
|
||||
scrollToIndex: () => undefined
|
||||
// Scrolls the least the target allows, like `align: 'auto'`.
|
||||
scrollToIndex: (index: number) => {
|
||||
setWindowStart((current) => {
|
||||
const lastStart = Math.max(0, count - windowSize)
|
||||
if (index < current) {
|
||||
return Math.min(index, lastStart)
|
||||
}
|
||||
if (index >= current + windowSize) {
|
||||
return Math.min(index - windowSize + 1, lastStart)
|
||||
}
|
||||
return current
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user