diff --git a/apps/mobile/src/components/agents/session-filter-button.mounted.test.tsx b/apps/mobile/src/components/agents/session-filter-button.mounted.test.tsx new file mode 100644 index 0000000000..dc7aa40b19 --- /dev/null +++ b/apps/mobile/src/components/agents/session-filter-button.mounted.test.tsx @@ -0,0 +1,119 @@ +// eslint-disable-next-line import/no-nodejs-modules -- Use the compiler's CommonJS entry, like Metro; its ESM entry cannot import debug in Node. +import { createRequire } from 'node:module'; +import { type ComponentProps } from 'react'; +import { Pressable } from 'react-native'; +import type * as NativeCompiler from 'react-native-css/compiler'; +import { compile as compileTailwind } from 'tailwindcss'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SlidersHorizontal } from '@/components/ui/icons'; +import { Text } from '@/components/ui/text'; +import { i18n } from '@/i18n'; +import { act, TestRenderer } from '@/test/renderer'; +import { SessionFilterButton } from './session-filter-button'; + +const { compile: compileNative } = createRequire(import.meta.url)( + 'react-native-css/compiler' +) as typeof NativeCompiler; + +vi.mock('react-native', () => ({ Pressable: 'Pressable', View: 'View' })); +vi.mock('@/components/ui/icons', () => ({ SlidersHorizontal: 'SlidersHorizontal' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#111111', mutedForeground: '#777777' }), +})); + +let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + +afterEach(() => { + renderer?.unmount(); + renderer = undefined; +}); + +function renderButton(activeCount: number, onPress = vi.fn<() => void>()) { + act(() => { + const element = ( + + ); + if (renderer) { + renderer.update(element); + } else { + renderer = TestRenderer.create(element); + } + }); + if (!renderer) { + throw new Error('Missing filter button'); + } + return renderer.root.findByType(Pressable); +} + +describe('SessionFilterButton', () => { + it.each([0, 1, 12])('reserves a nonshrinking 44dp target with %s filters', activeCount => { + const button = renderButton(activeCount); + const props = button.props as ComponentProps; + + // Native hitSlop is clipped to the parent. The layout box itself must + // provide the full touch target at the trailing edge of either header. + expect(props.className?.split(' ')).toEqual( + expect.arrayContaining(['h-[44px]', 'w-[44px]', 'shrink-0', 'items-center', 'justify-center']) + ); + expect(props.hitSlop).toBeUndefined(); + expect(button.findByType(SlidersHorizontal).props.size).toBe(20); + }); + + it('compiles the target to 44 native units rather than rem-scaled spacing', async () => { + const button = renderButton(0); + const props = button.props as ComponentProps; + const utilities = await compileTailwind('@tailwind utilities;'); + const css = utilities.build(props.className?.split(' ') ?? []); + const sheet = compileNative(css, { inlineVariables: false }).stylesheet(); + const rules = Object.fromEntries(sheet.s ?? []); + + for (const [utility, declaration] of [ + ['h-[44px]', { height: 44 }], + ['w-[44px]', { width: 44 }], + ['shrink-0', { flexShrink: 0 }], + ] as const) { + expect(rules[utility]).toEqual( + expect.arrayContaining([expect.objectContaining({ d: [declaration] })]) + ); + } + }); + + it('preserves the target and action as filters are applied and cleared', () => { + const onPress = vi.fn<() => void>(); + const initial = renderButton(0, onPress); + const className = initial.props.className; + const label = i18n.t('agentChat.sessionFilter.title'); + + for (const count of [0, 2, 12, 0]) { + const button = renderButton(count, onPress); + expect(button).toBe(initial); + expect(button.props.className).toBe(className); + expect(button.props.accessibilityRole).toBe('button'); + expect(button.props.accessibilityLabel).toBe(count > 0 ? `${label}, ${count}` : label); + expect(button.props.testID).toBe('agents-open-filters'); + expect(button.props.className).toContain('active:opacity-70'); + expect(button.findByType(SlidersHorizontal).props.color).toBe( + count > 0 ? '#111111' : '#777777' + ); + const badges = button.findAllByType(Text); + expect(badges).toHaveLength(count > 0 ? 1 : 0); + if (count > 0) { + const badge = button.findByType(Text); + expect(badge.props.children).toBe(count); + expect(badge.parent?.props.pointerEvents).toBe('none'); + expect(badge.parent?.props.className).toContain('absolute -right-1.5 -top-1.5'); + expect(badge.parent?.parent).toBe(button.findByType(SlidersHorizontal).parent); + } + act(() => { + (button.props.onPress as () => void)(); + }); + } + expect(onPress).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/mobile/src/components/agents/session-filter-button.tsx b/apps/mobile/src/components/agents/session-filter-button.tsx index a2fdba02de..7c95af6791 100644 --- a/apps/mobile/src/components/agents/session-filter-button.tsx +++ b/apps/mobile/src/components/agents/session-filter-button.tsx @@ -30,8 +30,6 @@ export function SessionFilterButton({ return ( - - {isActive ? ( - // Overlaps the icon's top-right corner; `pointer-events-none` keeps the - // whole 44pt target on the Pressable underneath. - - + + {isActive ? ( + // Overlaps the icon's top-right corner; `pointer-events-none` keeps the + // whole 44pt target on the Pressable underneath. + - {activeCount} - - - ) : null} + + {activeCount} + + + ) : null} + ); }