116 lines
4.3 KiB
TypeScript
116 lines
4.3 KiB
TypeScript
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||
|
|
import { PhotoCarousel } from '@/components/PhotoCarousel';
|
||
|
|
import type { ListingDetailPhoto } from '@/types';
|
||
|
|
|
||
|
|
function photo(url: string, caption: string | null = null): ListingDetailPhoto {
|
||
|
|
return { url, caption, type: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('PhotoCarousel', () => {
|
||
|
|
describe('B23 — single-photo and broken-image handling', () => {
|
||
|
|
it('suppresses the N/M counter when there is exactly one photo', () => {
|
||
|
|
render(<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />);
|
||
|
|
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('renders the N/M counter when there are multiple photos', () => {
|
||
|
|
render(
|
||
|
|
<PhotoCarousel
|
||
|
|
photos={[
|
||
|
|
photo('https://example.com/a.jpg'),
|
||
|
|
photo('https://example.com/b.jpg'),
|
||
|
|
photo('https://example.com/c.jpg'),
|
||
|
|
]}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('replaces a broken image with a placeholder tile when onError fires', () => {
|
||
|
|
render(
|
||
|
|
<PhotoCarousel
|
||
|
|
photos={[
|
||
|
|
photo('https://example.com/good.jpg'),
|
||
|
|
photo('https://example.com/bad.jpg'),
|
||
|
|
]}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
const imgs = screen.getAllByRole('img');
|
||
|
|
expect(imgs).toHaveLength(2);
|
||
|
|
// Simulate the second image failing to load
|
||
|
|
fireEvent.error(imgs[1]);
|
||
|
|
// After error, the placeholder appears and the broken img is gone
|
||
|
|
expect(screen.getByText(/Photo unavailable/i)).toBeInTheDocument();
|
||
|
|
// The good image is still rendered
|
||
|
|
expect(screen.getAllByRole('img')).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still renders "No photos available" when photos is empty', () => {
|
||
|
|
render(<PhotoCarousel photos={[]} />);
|
||
|
|
expect(screen.getByText(/No photos available/i)).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('B26 — single-photo carousel should not loop', () => {
|
||
|
|
it('does not render prev/next buttons for a single-photo carousel', () => {
|
||
|
|
render(<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />);
|
||
|
|
expect(screen.queryByLabelText(/Previous photo/i)).not.toBeInTheDocument();
|
||
|
|
expect(screen.queryByLabelText(/Next photo/i)).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not render dots for a single-photo carousel', () => {
|
||
|
|
const { container } = render(
|
||
|
|
<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />,
|
||
|
|
);
|
||
|
|
// No "Go to photo X" buttons (dots) should be rendered when single
|
||
|
|
expect(container.querySelectorAll('button[aria-label^="Go to photo"]'))
|
||
|
|
.toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('renders prev/next + dots for a multi-photo carousel', () => {
|
||
|
|
render(
|
||
|
|
<PhotoCarousel
|
||
|
|
photos={[
|
||
|
|
photo('https://example.com/a.jpg'),
|
||
|
|
photo('https://example.com/b.jpg'),
|
||
|
|
]}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
expect(screen.getByLabelText(/Previous photo/i)).toBeInTheDocument();
|
||
|
|
expect(screen.getByLabelText(/Next photo/i)).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('B16 — keyboard navigation', () => {
|
||
|
|
it('makes the multi-photo carousel root keyboard-focusable', () => {
|
||
|
|
const { container } = render(
|
||
|
|
<PhotoCarousel
|
||
|
|
photos={[
|
||
|
|
photo('https://example.com/a.jpg'),
|
||
|
|
photo('https://example.com/b.jpg'),
|
||
|
|
]}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
// The Embla overflow container is the focusable root
|
||
|
|
const region = container.querySelector('[role="region"][aria-label="Property photos"]');
|
||
|
|
expect(region).not.toBeNull();
|
||
|
|
// tabIndex should be set to 0 so the carousel can receive keydown
|
||
|
|
expect((region as HTMLElement).tabIndex).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not make a single-photo carousel keyboard-focusable', () => {
|
||
|
|
const { container } = render(
|
||
|
|
<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />,
|
||
|
|
);
|
||
|
|
const region = container.querySelector('[role="region"][aria-label="Property photos"]');
|
||
|
|
// Region still exists (Embla wrapping) but should not be focusable, since
|
||
|
|
// there's nothing to navigate to.
|
||
|
|
expect(region).not.toBeNull();
|
||
|
|
const tabIndex = (region as HTMLElement).tabIndex;
|
||
|
|
// Default tabIndex on non-interactive elements is -1; we only flip to 0
|
||
|
|
// for multi-photo carousels.
|
||
|
|
expect(tabIndex).toBeLessThan(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|