|
16 | 16 | */ |
17 | 17 |
|
18 | 18 | import React from 'react'; |
19 | | -import { Text } from 'react-native'; |
20 | | -import { render, fireEvent, waitFor } from '@testing-library/react-native'; |
| 19 | +import { Text, Keyboard } from 'react-native'; |
| 20 | +import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; |
21 | 21 | import { AppSheet } from '../../../src/components/AppSheet'; |
22 | 22 |
|
23 | 23 | describe('AppSheet', () => { |
@@ -275,6 +275,193 @@ describe('AppSheet', () => { |
275 | 275 | }); |
276 | 276 | }); |
277 | 277 |
|
| 278 | + // ============================================================================ |
| 279 | + // Keyboard Dismiss Before Open |
| 280 | + // ============================================================================ |
| 281 | + describe('keyboard dismiss before open', () => { |
| 282 | + let mockRemove: jest.Mock; |
| 283 | + let mockAddListener: jest.SpyInstance; |
| 284 | + let mockDismiss: jest.SpyInstance; |
| 285 | + let mockIsVisible: jest.SpyInstance; |
| 286 | + |
| 287 | + beforeEach(() => { |
| 288 | + mockRemove = jest.fn(); |
| 289 | + mockAddListener = jest.spyOn(Keyboard, 'addListener').mockReturnValue({ |
| 290 | + remove: mockRemove, |
| 291 | + } as any); |
| 292 | + mockDismiss = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => { }); |
| 293 | + mockIsVisible = jest.spyOn(Keyboard, 'isVisible' as any); |
| 294 | + }); |
| 295 | + |
| 296 | + afterEach(() => { |
| 297 | + mockAddListener.mockRestore(); |
| 298 | + mockDismiss.mockRestore(); |
| 299 | + mockIsVisible.mockRestore(); |
| 300 | + }); |
| 301 | + |
| 302 | + it('opens modal immediately when keyboard is not visible', () => { |
| 303 | + mockIsVisible.mockReturnValue(false); |
| 304 | + |
| 305 | + const { toJSON } = render( |
| 306 | + <AppSheet visible={true} onClose={jest.fn()} title="Sheet"> |
| 307 | + <Text>Content</Text> |
| 308 | + </AppSheet> |
| 309 | + ); |
| 310 | + |
| 311 | + expect(Keyboard.dismiss).not.toHaveBeenCalled(); |
| 312 | + // addListener may be called by KeyboardAvoidingView internally, |
| 313 | + // but should NOT be called with 'keyboardDidHide' by our code |
| 314 | + const didHideCalls = mockAddListener.mock.calls.filter( |
| 315 | + (call: any[]) => call[0] === 'keyboardDidHide', |
| 316 | + ); |
| 317 | + expect(didHideCalls).toHaveLength(0); |
| 318 | + expect(toJSON()).toBeTruthy(); |
| 319 | + }); |
| 320 | + |
| 321 | + it('dismisses keyboard and defers modal when keyboard is visible', () => { |
| 322 | + mockIsVisible.mockReturnValue(true); |
| 323 | + |
| 324 | + const { toJSON } = render( |
| 325 | + <AppSheet visible={false} onClose={jest.fn()} title="Sheet"> |
| 326 | + <Text>Content</Text> |
| 327 | + </AppSheet> |
| 328 | + ); |
| 329 | + |
| 330 | + // Initially not visible |
| 331 | + expect(toJSON()).toBeNull(); |
| 332 | + |
| 333 | + // Now set visible — keyboard is open |
| 334 | + render( |
| 335 | + <AppSheet visible={true} onClose={jest.fn()} title="Sheet"> |
| 336 | + <Text>Content</Text> |
| 337 | + </AppSheet> |
| 338 | + ); |
| 339 | + |
| 340 | + expect(Keyboard.dismiss).toHaveBeenCalled(); |
| 341 | + expect(Keyboard.addListener).toHaveBeenCalledWith( |
| 342 | + 'keyboardDidHide', |
| 343 | + expect.any(Function), |
| 344 | + ); |
| 345 | + }); |
| 346 | + |
| 347 | + it('opens modal after keyboardDidHide event fires', async () => { |
| 348 | + mockIsVisible.mockReturnValue(true); |
| 349 | + let keyboardHideCallback: (() => void) | null = null; |
| 350 | + mockAddListener.mockImplementation((_event: string, cb: () => void) => { |
| 351 | + keyboardHideCallback = cb; |
| 352 | + return { remove: mockRemove }; |
| 353 | + }); |
| 354 | + |
| 355 | + const { rerender, getByText } = render( |
| 356 | + <AppSheet visible={false} onClose={jest.fn()} title="Sheet"> |
| 357 | + <Text>Content</Text> |
| 358 | + </AppSheet> |
| 359 | + ); |
| 360 | + |
| 361 | + // Open the sheet — keyboard is visible, so modal deferred |
| 362 | + rerender( |
| 363 | + <AppSheet visible={true} onClose={jest.fn()} title="Sheet"> |
| 364 | + <Text>Content</Text> |
| 365 | + </AppSheet> |
| 366 | + ); |
| 367 | + |
| 368 | + expect(Keyboard.dismiss).toHaveBeenCalled(); |
| 369 | + |
| 370 | + // Simulate keyboard finishing its dismiss |
| 371 | + await act(() => { |
| 372 | + keyboardHideCallback!(); |
| 373 | + }); |
| 374 | + |
| 375 | + // Modal should now be visible with content |
| 376 | + expect(getByText('Sheet')).toBeTruthy(); |
| 377 | + expect(mockRemove).toHaveBeenCalled(); |
| 378 | + }); |
| 379 | + |
| 380 | + it('opens modal via safety timeout if keyboardDidHide never fires', async () => { |
| 381 | + jest.useFakeTimers(); |
| 382 | + mockIsVisible.mockReturnValue(true); |
| 383 | + |
| 384 | + const { rerender, getByText } = render( |
| 385 | + <AppSheet visible={false} onClose={jest.fn()} title="Sheet"> |
| 386 | + <Text>Content</Text> |
| 387 | + </AppSheet> |
| 388 | + ); |
| 389 | + |
| 390 | + rerender( |
| 391 | + <AppSheet visible={true} onClose={jest.fn()} title="Sheet"> |
| 392 | + <Text>Content</Text> |
| 393 | + </AppSheet> |
| 394 | + ); |
| 395 | + |
| 396 | + expect(Keyboard.dismiss).toHaveBeenCalled(); |
| 397 | + |
| 398 | + // Fast-forward past the 400ms safety timeout |
| 399 | + await act(() => { |
| 400 | + jest.advanceTimersByTime(400); |
| 401 | + }); |
| 402 | + |
| 403 | + expect(getByText('Sheet')).toBeTruthy(); |
| 404 | + expect(mockRemove).toHaveBeenCalled(); |
| 405 | + |
| 406 | + jest.useRealTimers(); |
| 407 | + }); |
| 408 | + |
| 409 | + it('does not open modal twice if both listener and timeout fire', async () => { |
| 410 | + jest.useFakeTimers(); |
| 411 | + mockIsVisible.mockReturnValue(true); |
| 412 | + let keyboardHideCallback: (() => void) | null = null; |
| 413 | + mockAddListener.mockImplementation((_event: string, cb: () => void) => { |
| 414 | + keyboardHideCallback = cb; |
| 415 | + return { remove: mockRemove }; |
| 416 | + }); |
| 417 | + |
| 418 | + const { rerender } = render( |
| 419 | + <AppSheet visible={false} onClose={jest.fn()} title="Sheet"> |
| 420 | + <Text>Content</Text> |
| 421 | + </AppSheet> |
| 422 | + ); |
| 423 | + |
| 424 | + rerender( |
| 425 | + <AppSheet visible={true} onClose={jest.fn()} title="Sheet"> |
| 426 | + <Text>Content</Text> |
| 427 | + </AppSheet> |
| 428 | + ); |
| 429 | + |
| 430 | + // Fire the keyboard hide callback |
| 431 | + await act(() => { |
| 432 | + keyboardHideCallback!(); |
| 433 | + }); |
| 434 | + |
| 435 | + // Also fire the timeout — should be a no-op |
| 436 | + await act(() => { |
| 437 | + jest.advanceTimersByTime(400); |
| 438 | + }); |
| 439 | + |
| 440 | + // No errors — the guard prevents double setState |
| 441 | + jest.useRealTimers(); |
| 442 | + }); |
| 443 | + |
| 444 | + it('cleans up listener and timeout on unmount during keyboard dismiss', () => { |
| 445 | + jest.useFakeTimers(); |
| 446 | + mockIsVisible.mockReturnValue(true); |
| 447 | + |
| 448 | + const { unmount } = render( |
| 449 | + <AppSheet visible={true} onClose={jest.fn()} title="Sheet"> |
| 450 | + <Text>Content</Text> |
| 451 | + </AppSheet> |
| 452 | + ); |
| 453 | + |
| 454 | + expect(Keyboard.addListener).toHaveBeenCalled(); |
| 455 | + |
| 456 | + unmount(); |
| 457 | + |
| 458 | + // Cleanup should have removed the listener |
| 459 | + expect(mockRemove).toHaveBeenCalled(); |
| 460 | + |
| 461 | + jest.useRealTimers(); |
| 462 | + }); |
| 463 | + }); |
| 464 | + |
278 | 465 | // ============================================================================ |
279 | 466 | // Visibility Transitions |
280 | 467 | // ============================================================================ |
|
0 commit comments