212 lines
5.9 KiB
TypeScript
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)
|
|
})
|
|
})
|