orca/mobile/src/components/WorktreeListRow.test.ts

212 lines
5.9 KiB
TypeScript

import {
createElement,
Fragment,
useCallback,
useState,
type Dispatch,
type SetStateAction
} from 'react'
import { Text } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { WorktreeAgentRow } from './WorktreeAgentRow'
import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow'
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
agentSpinnerRender: vi.fn(),
agentStateDotRender: vi.fn()
}))
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Bell: 'Bell',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
GitBranch: 'GitBranch',
GitPullRequest: 'GitPullRequest'
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
vi.mock('./AgentSpinner', () => ({
AgentSpinner: (props: unknown) => {
agentSpinnerRender(props)
return null
}
}))
vi.mock('./AgentStateDot', () => ({
AgentStateDot: (props: unknown) => {
agentStateDotRender(props)
return null
}
}))
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
vi.mock('./WorktreeMetaGlyphs', () => ({
prStateColor: () => '#000000',
WorktreeMetaGlyphs: () => null
}))
type TestItem = WorktreeListRowItem & {
status: 'working' | 'active' | 'permission' | 'done' | 'inactive'
lastOutputAt: number
}
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
const [sibling, setSibling] = useState(0)
updateSibling = setSibling
const onPress = useCallback(() => undefined, [])
const onLongPress = useCallback(() => undefined, [])
const onToggleLineage = useCallback(() => undefined, [])
// Sibling state changes re-render the harness without changing the row's props,
// exercising the row's React.memo bailout.
return createElement(
Fragment,
null,
createElement(Text, null, sibling),
createElement(WorktreeListRow, {
item,
isReadOnly: false,
now,
repoColor: '#000000',
repoIcon: stableRepoIcon,
hideRepo: false,
status: item.status,
onPress,
onLongPress,
onToggleLineage
})
)
}
function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
return {
paneKey: 'agent-1',
parentPaneKey: null,
state: 'working',
agentType: null,
prompt: 'Fix the list',
taskTitle: null,
displayName: null,
lastAssistantMessage: null,
toolName: null,
toolInput: null,
interrupted: false,
stateStartedAt: 1_000,
updatedAt: 1_000,
...overrides
}
}
const baseItem: TestItem = {
worktreeId: 'worktree-1',
repo: 'orca',
branch: 'feature/mobile-list',
displayName: 'mobile-list',
liveTerminalCount: 1,
preview: 'Waiting',
unread: false,
linkedPR: null,
agents: [agent()],
status: 'active',
lastOutputAt: 1_000
}
describe('memoized worktree rows', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
agentSpinnerRender.mockClear()
agentStateDotRender.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('skips an unrelated parent render but updates for live item fields and time', async () => {
await act(async () => {
renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
})
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
await act(async () => updateSibling((value) => value + 1))
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
let expectedRenders = 1
const liveUpdates: TestItem[] = [
{ ...baseItem, preview: 'Running tests' },
{ ...baseItem, unread: true },
{ ...baseItem, lastOutputAt: 2_000 },
{ ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] },
{ ...baseItem, status: 'working' }
]
for (const liveUpdate of liveUpdates) {
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
}
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
})
it('memoizes agent rows without hiding agent updates', async () => {
const firstAgent = agent()
await act(async () => {
renderer = create(
createElement(WorktreeAgentRow, {
agent: firstAgent,
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: firstAgent,
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: agent({ state: 'done', updatedAt: 2_000 }),
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
})
})