File size: 6,634 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
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<IntersectionObserverEntry>[]) => void;
  constructor(callback: (entries: Partial<IntersectionObserverEntry>[]) => 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(<PlaceAvatar place={basePlaceWithImage} />);
    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(<PlaceAvatar place={basePlaceWithImage} />);
    const img = screen.getByAltText('Eiffel Tower');
    expect(img).toBeTruthy();
  });

  it('FE-COMP-AVATAR-003: renders an icon (no img) when no image_url', () => {
    render(<PlaceAvatar place={basePlaceNoImage} />);
    expect(screen.queryByRole('img')).toBeNull();
    // The wrapper div should still be present
    const { container } = render(<PlaceAvatar place={basePlaceNoImage} />);
    expect(container.querySelector('div')).toBeTruthy();
  });

  it('FE-COMP-AVATAR-004: uses category color as background color', () => {
    const { container } = render(
      <PlaceAvatar place={basePlaceWithImage} category={{ color: '#ff5733', icon: 'MapPin' }} />
    );
    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(<PlaceAvatar place={basePlaceWithImage} />);
    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(<PlaceAvatar place={basePlaceWithImage} />);
    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(<PlaceAvatar place={basePlaceWithImage} size={64} />);
    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(<PlaceAvatar place={basePlaceWithImage} />);
    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(<PlaceAvatar place={basePlaceNoImage} />);
    expect(container.querySelector('svg')).toBeTruthy();
  });

  it('FE-COMP-AVATAR-010: uses category-specific icon when category.icon is set', () => {
    const { container } = render(
      <PlaceAvatar place={basePlaceNoImage} category={{ icon: 'MapPin', color: '#ff0000' }} />
    );
    expect(container.querySelector('svg')).toBeTruthy();
  });

  it('FE-COMP-AVATAR-011: calls fetchPhoto when visible and no image_url, no cache', () => {
    render(<PlaceAvatar place={basePlaceNoImage} />);

    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(
      <PlaceAvatar place={{ ...basePlaceNoImage, google_place_id: 'gid123' }} />
    );

    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(<PlaceAvatar place={{ ...basePlaceNoImage, google_place_id: 'gid456' }} />);

    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(<PlaceAvatar place={basePlaceWithImage} />);
    expect(vi.mocked(fetchPhoto)).not.toHaveBeenCalled();
  });

  it('FE-COMP-AVATAR-015: IntersectionObserver disconnected on unmount', () => {
    const { unmount } = render(<PlaceAvatar place={basePlaceNoImage} />);
    unmount();
    expect(mockDisconnect).toHaveBeenCalled();
  });

  it('FE-COMP-AVATAR-016: does not set up IntersectionObserver when image_url present', () => {
    render(<PlaceAvatar place={basePlaceWithImage} />);
    expect(mockObserve).not.toHaveBeenCalled();
  });
});