Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions __tests__/src/components/ImageFailureMessage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,23 @@ describe('ImageFailureMessage', () => {
it('does not render when no images have failed', () => {
const { container } = render(
<FailedImageProvider>
<ImageFailureMessage />
<ImageFailureMessage imageUrls={['https://example.com/image1.jpg']} />
</FailedImageProvider>,
);

expect(container).toBeEmptyDOMElement();
});

it('renders default translated message when images have failed', () => {
// Mock the context with hasFailed: true
it('renders message when a specific image has failed', () => {
const mockContext = {
failedImages: new Set(['https://example.com/image1.jpg']),
fallbackImage: 'data:image/svg+xml,...',
hasFailed: true,
notifyFailure: vi.fn(),
};

render(
<FailedImageContext.Provider value={mockContext}>
<ImageFailureMessage />
<ImageFailureMessage imageUrls={['https://example.com/image1.jpg']} />
</FailedImageContext.Provider>,
);

Expand All @@ -33,16 +32,44 @@ describe('ImageFailureMessage', () => {
expect(screen.getByText(/See console for details/i)).toBeInTheDocument();
});


it('isolates failures per window', () => {
const mockContext = {
failedImages: new Set(['https://example.com/manifest-a/canvas-1/image.jpg']),
fallbackImage: 'data:image/svg+xml,...',
notifyFailure: vi.fn(),
};

// Render Window A with its failed image
const { container: containerA } = render(
<FailedImageContext.Provider value={mockContext}>
<ImageFailureMessage imageUrls={['https://example.com/manifest-a/canvas-1/image.jpg']} />
</FailedImageContext.Provider>,
);

expect(containerA).not.toBeEmptyDOMElement();
expect(screen.getByText(/Problem loading image/i)).toBeInTheDocument();

// Render Window B with non-failed image
const { container: containerB } = render(
<FailedImageContext.Provider value={mockContext}>
<ImageFailureMessage imageUrls={['https://example.com/manifest-b/canvas-1/image.jpg']} />
</FailedImageContext.Provider>,
);

expect(containerB).toBeEmptyDOMElement();
});

it('has proper accessibility attributes', () => {
const mockContext = {
failedImages: new Set(['https://example.com/image1.jpg']),
fallbackImage: 'data:image/svg+xml,...',
hasFailed: true,
notifyFailure: vi.fn(),
};

render(
<FailedImageContext.Provider value={mockContext}>
<ImageFailureMessage />
<ImageFailureMessage imageUrls={['https://example.com/image1.jpg']} />
</FailedImageContext.Provider>,
);

Expand Down
29 changes: 29 additions & 0 deletions __tests__/src/components/OpenSeadragonViewer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { OpenSeadragonViewer } from '../../../src/components/OpenSeadragonViewer
import fixture from '../../fixtures/version-2/019.json';
import { OSDReferences } from '../../../src/plugins/OSDReferences';

vi.mock('../../../src/components/ImageFailureMessage', () => ({
ImageFailureMessage: vi.fn(() => null),
}));

const canvases = Utils.parseManifest(fixture).getSequences()[0].getCanvases();

/**
Expand Down Expand Up @@ -115,4 +119,29 @@ describe('OpenSeadragonViewer', () => {
vi.useRealTimers();
});
});

describe('ImageFailureMessage', () => {
it('passes imageUrls from infoResponses and nonTiledImages', async () => {
const { ImageFailureMessage } = await import('../../../src/components/ImageFailureMessage');

const infoResponses = [
{ id: 'http://example.com/image1', json: {} },
{ id: 'http://example.com/image2', json: {} },
];
const nonTiledImages = [
{ id: 'http://example.com/image3', getProperty: () => 'Image' },
];

createWrapper({ infoResponses, nonTiledImages });

// lastCall[0] returns props object
expect(ImageFailureMessage.mock.lastCall[0]).toMatchObject({
imageUrls: [
'http://example.com/image1',
'http://example.com/image2',
'http://example.com/image3',
],
});
});
});
});
17 changes: 6 additions & 11 deletions __tests__/src/contexts/FailedImageProvider.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,18 @@ describe('FailedImageProvider', () => {
expect(result.current.fallbackImage).toContain('%231967d2'); // Blue color
});

it('provides hasFailed as false initially', () => {
it('provides failedImages as empty Set initially and can add to it', () => {
const wrapper = ({ children }) => <FailedImageProvider>{children}</FailedImageProvider>;
const { result } = renderHook(() => useContext(FailedImageContext), { wrapper });

expect(result.current.hasFailed).toBe(false);
});

it('sets hasFailed to true after notifyFailure is called', () => {
const wrapper = ({ children }) => <FailedImageProvider>{children}</FailedImageProvider>;
const { result } = renderHook(() => useContext(FailedImageContext), { wrapper });

expect(result.current.hasFailed).toBe(false);
expect(result.current.failedImages).toBeInstanceOf(Set);
expect(result.current.failedImages.size).toBe(0);

act(() => {
result.current.notifyFailure();
result.current.notifyFailure('https://example.com/image1.jpg');
});

expect(result.current.hasFailed).toBe(true);
expect(result.current.failedImages.size).toBe(1);
expect(result.current.failedImages.has('https://example.com/image1.jpg')).toBe(true);
});
});
2 changes: 1 addition & 1 deletion src/components/IIIFThumbnail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const LazyLoadedImage = ({
style={imageStyles}
onError={() => {
setFailed(true);
notifyFailure();
if (finalSrc) notifyFailure(finalSrc);
}}
{...props}
/>
Expand Down
13 changes: 10 additions & 3 deletions src/components/ImageFailureMessage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import PropTypes from 'prop-types';
import FailedImageContext from '../contexts/FailedImageContext';

const MessageContainer = styled(Box)(({ theme }) => ({
Expand All @@ -21,11 +22,13 @@ const MessageContainer = styled(Box)(({ theme }) => ({
/**
* Displays an accessible message when images fail to load in the OSD viewer
*/
export function ImageFailureMessage() {
const { hasFailed } = useContext(FailedImageContext);
export function ImageFailureMessage({ imageUrls = [] }) {
const { failedImages } = useContext(FailedImageContext);
const { t } = useTranslation();

if (!hasFailed) return null;
const hasFailedImage = imageUrls.some(url => failedImages.has(url));

if (!hasFailedImage) return null;

return (
<MessageContainer role="status" aria-live="polite">
Expand All @@ -35,3 +38,7 @@ export function ImageFailureMessage() {
</MessageContainer>
);
}

ImageFailureMessage.propTypes = {
imageUrls: PropTypes.arrayOf(PropTypes.string),
};
3 changes: 2 additions & 1 deletion src/components/OpenSeadragonTileSource.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ export default function OpenSeadragonTileSource({
success: (event) => resolve(event),

error: (event) => {
notifyFailure();
const imageUrl = url || (typeof tileSource === 'string' ? tileSource : tileSource?.['@id']);
if (imageUrl) notifyFailure(imageUrl);
loadFallback();

reject(event);
Expand Down
9 changes: 7 additions & 2 deletions src/components/OpenSeadragonViewer.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
useRef, Children, cloneElement, useCallback, useState, useEffect,
useRef, Children, cloneElement, useCallback, useState, useEffect, useMemo,
} from 'react';
import PropTypes from 'prop-types';
import { styled } from '@mui/material/styles';
Expand Down Expand Up @@ -85,6 +85,11 @@ export function OpenSeadragonViewer({
...rest,
};

const imageUrls = useMemo(() => [
...infoResponses.map(info => info.id),
...nonTiledImages.map(img => img.id),
], [infoResponses, nonTiledImages]);

return (
<OpenSeadragonComponent
className={classNames(ns('osd-container'))}
Expand Down Expand Up @@ -138,7 +143,7 @@ export function OpenSeadragonViewer({
{ drawAnnotations
&& <AnnotationsOverlay viewer={viewer} windowId={windowId} /> }
{ enhancedChildren }
<ImageFailureMessage />
<ImageFailureMessage imageUrls={imageUrls} />
<PluginHook targetName="OpenSeadragonViewer" viewer={viewer} {...pluginProps} />
</OpenSeadragonComponent>
);
Expand Down
8 changes: 4 additions & 4 deletions src/contexts/FailedImageProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ const defaultFallback = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000

export default function FailedImageProvider({ children }) {
const fallbackImage = config.fallbackImage || defaultFallback;
const [hasFailed, setHasFailed] = useState(false);
const [failedImages, setFailedImages] = useState(new Set());

const notifyFailure = useCallback(() => {
setHasFailed(true);
const notifyFailure = useCallback((imageId) => {
setFailedImages((prev) => new Set(prev).add(imageId));
}, []);

return (
<FailedImageContext.Provider value={{
failedImages,
fallbackImage,
hasFailed,
notifyFailure,
}}
>
Expand Down
Loading