diff --git a/__tests__/src/components/ImageFailureMessage.test.jsx b/__tests__/src/components/ImageFailureMessage.test.jsx index 4c7db194a..f231dc9a3 100644 --- a/__tests__/src/components/ImageFailureMessage.test.jsx +++ b/__tests__/src/components/ImageFailureMessage.test.jsx @@ -7,24 +7,23 @@ describe('ImageFailureMessage', () => { it('does not render when no images have failed', () => { const { container } = render( - + , ); 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( - + , ); @@ -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( + + + , + ); + + expect(containerA).not.toBeEmptyDOMElement(); + expect(screen.getByText(/Problem loading image/i)).toBeInTheDocument(); + + // Render Window B with non-failed image + const { container: containerB } = render( + + + , + ); + + 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( - + , ); diff --git a/__tests__/src/components/OpenSeadragonViewer.test.js b/__tests__/src/components/OpenSeadragonViewer.test.js index 89e744016..4006b10ab 100644 --- a/__tests__/src/components/OpenSeadragonViewer.test.js +++ b/__tests__/src/components/OpenSeadragonViewer.test.js @@ -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(); /** @@ -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', + ], + }); + }); + }); }); diff --git a/__tests__/src/contexts/FailedImageProvider.test.jsx b/__tests__/src/contexts/FailedImageProvider.test.jsx index 15aa20f5e..b9fd66c81 100644 --- a/__tests__/src/contexts/FailedImageProvider.test.jsx +++ b/__tests__/src/contexts/FailedImageProvider.test.jsx @@ -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 }) => {children}; const { result } = renderHook(() => useContext(FailedImageContext), { wrapper }); - expect(result.current.hasFailed).toBe(false); - }); - - it('sets hasFailed to true after notifyFailure is called', () => { - const wrapper = ({ children }) => {children}; - 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); }); }); diff --git a/src/components/IIIFThumbnail.jsx b/src/components/IIIFThumbnail.jsx index 75cf653db..4395560ba 100644 --- a/src/components/IIIFThumbnail.jsx +++ b/src/components/IIIFThumbnail.jsx @@ -130,7 +130,7 @@ const LazyLoadedImage = ({ style={imageStyles} onError={() => { setFailed(true); - notifyFailure(); + if (finalSrc) notifyFailure(finalSrc); }} {...props} /> diff --git a/src/components/ImageFailureMessage.jsx b/src/components/ImageFailureMessage.jsx index 35bb8f05d..ae209641a 100644 --- a/src/components/ImageFailureMessage.jsx +++ b/src/components/ImageFailureMessage.jsx @@ -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 }) => ({ @@ -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 ( @@ -35,3 +38,7 @@ export function ImageFailureMessage() { ); } + +ImageFailureMessage.propTypes = { + imageUrls: PropTypes.arrayOf(PropTypes.string), +}; diff --git a/src/components/OpenSeadragonTileSource.jsx b/src/components/OpenSeadragonTileSource.jsx index f8879cb33..e8b17371a 100644 --- a/src/components/OpenSeadragonTileSource.jsx +++ b/src/components/OpenSeadragonTileSource.jsx @@ -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); diff --git a/src/components/OpenSeadragonViewer.jsx b/src/components/OpenSeadragonViewer.jsx index 851cc31bd..41bb31742 100644 --- a/src/components/OpenSeadragonViewer.jsx +++ b/src/components/OpenSeadragonViewer.jsx @@ -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'; @@ -85,6 +85,11 @@ export function OpenSeadragonViewer({ ...rest, }; + const imageUrls = useMemo(() => [ + ...infoResponses.map(info => info.id), + ...nonTiledImages.map(img => img.id), + ], [infoResponses, nonTiledImages]); + return ( } { enhancedChildren } - + ); diff --git a/src/contexts/FailedImageProvider.jsx b/src/contexts/FailedImageProvider.jsx index 33369e1e3..c81ee58b1 100644 --- a/src/contexts/FailedImageProvider.jsx +++ b/src/contexts/FailedImageProvider.jsx @@ -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 (