import { render, screen, fireEvent, act } from '../../../tests/helpers/render'; import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'; // Mock photoService — all functions are no-ops / return null vi.mock('../../services/photoService', () => ({ getCached: vi.fn(() => null), isLoading: vi.fn(() => false), fetchPhoto: vi.fn(), onThumbReady: vi.fn(() => () => {}), })); // Mock IntersectionObserver as a class constructor const mockDisconnect = vi.fn(); const mockObserve = vi.fn(); let observerInstance: MockIntersectionObserver | null = null; class MockIntersectionObserver { callback: (entries: Partial[]) => void; constructor(callback: (entries: Partial[]) => void) { this.callback = callback; observerInstance = this; } observe = mockObserve; disconnect = mockDisconnect; unobserve = vi.fn(); } beforeAll(() => { (globalThis as any).IntersectionObserver = MockIntersectionObserver; }); beforeEach(() => { vi.mocked(getCached).mockReturnValue(null); vi.mocked(isLoading).mockReturnValue(false); vi.mocked(fetchPhoto).mockReset(); vi.mocked(onThumbReady).mockReturnValue(() => {}); }); afterEach(() => { mockDisconnect.mockClear(); mockObserve.mockClear(); observerInstance = null; }); import PlaceAvatar from './PlaceAvatar'; const basePlaceNoImage = { id: 1, name: 'Eiffel Tower', image_url: null, google_place_id: null, osm_id: null, lat: 48.8584, lng: 2.2945, }; const basePlaceWithImage = { ...basePlaceNoImage, image_url: 'https://example.com/eiffel.jpg', }; describe('PlaceAvatar', () => { it('FE-COMP-AVATAR-001: renders an image when image_url is provided', () => { render(); const img = screen.getByRole('img'); expect(img).toBeTruthy(); expect((img as HTMLImageElement).src).toContain('eiffel.jpg'); }); it('FE-COMP-AVATAR-002: image has correct alt text equal to place.name', () => { render(); const img = screen.getByAltText('Eiffel Tower'); expect(img).toBeTruthy(); }); it('FE-COMP-AVATAR-003: renders an icon (no img) when no image_url', () => { render(); expect(screen.queryByRole('img')).toBeNull(); // The wrapper div should still be present const { container } = render(); expect(container.querySelector('div')).toBeTruthy(); }); it('FE-COMP-AVATAR-004: uses category color as background color', () => { const { container } = render( ); const wrapper = container.firstElementChild as HTMLElement; expect(wrapper.style.backgroundColor).toBe('rgb(255, 87, 51)'); }); it('FE-COMP-AVATAR-005: uses default indigo color when no category provided', () => { const { container } = render(); const wrapper = container.firstElementChild as HTMLElement; expect(wrapper.style.backgroundColor).toBe('rgb(99, 102, 241)'); }); it('FE-COMP-AVATAR-006: falls back to icon when image fails to load', () => { render(); const img = screen.getByRole('img'); // Simulate image load error act(() => { fireEvent.error(img); }); // After error, img is removed and icon takes over expect(screen.queryByRole('img')).toBeNull(); }); it('FE-COMP-AVATAR-007: respects the size prop for container dimensions', () => { const { container } = render(); const wrapper = container.firstElementChild as HTMLElement; expect(wrapper.style.width).toBe('64px'); expect(wrapper.style.height).toBe('64px'); }); it('FE-COMP-AVATAR-008: default size is 32px when size prop is omitted', () => { const { container } = render(); const wrapper = container.firstChild as HTMLElement; expect(wrapper.style.width).toBe('32px'); expect(wrapper.style.height).toBe('32px'); }); it('FE-COMP-AVATAR-009: uses category icon (SVG) when no category provided', () => { const { container } = render(); expect(container.querySelector('svg')).toBeTruthy(); }); it('FE-COMP-AVATAR-010: uses category-specific icon when category.icon is set', () => { const { container } = render( ); expect(container.querySelector('svg')).toBeTruthy(); }); it('FE-COMP-AVATAR-011: calls fetchPhoto when visible and no image_url, no cache', () => { render(); act(() => { observerInstance?.callback([{ isIntersecting: true }]); }); expect(vi.mocked(fetchPhoto)).toHaveBeenCalled(); }); it('FE-COMP-AVATAR-012: sets photoSrc from cached thumbnail when cache hit', () => { vi.mocked(getCached).mockReturnValue({ thumbDataUrl: 'data:image/jpeg;base64,abc', photoUrl: null } as any); const { container } = render( ); const img = container.querySelector('img') as HTMLImageElement; expect(img).toBeTruthy(); expect(img.src).toContain('data:image/jpeg;base64,abc'); }); it('FE-COMP-AVATAR-013: registers onThumbReady callback when photo is loading', () => { vi.mocked(getCached).mockReturnValue(null); vi.mocked(isLoading).mockReturnValue(true); render(); act(() => { observerInstance?.callback([{ isIntersecting: true }]); }); expect(vi.mocked(onThumbReady)).toHaveBeenCalledWith('gid456', expect.any(Function)); }); it('FE-COMP-AVATAR-014: does not call fetchPhoto when image_url is set', () => { render(); expect(vi.mocked(fetchPhoto)).not.toHaveBeenCalled(); }); it('FE-COMP-AVATAR-015: IntersectionObserver disconnected on unmount', () => { const { unmount } = render(); unmount(); expect(mockDisconnect).toHaveBeenCalled(); }); it('FE-COMP-AVATAR-016: does not set up IntersectionObserver when image_url present', () => { render(); expect(mockObserve).not.toHaveBeenCalled(); }); });