import { hideThumb, showBlock, showThumb, spoilerBlock, spoilerThumb } from '../image'; import { getRandomArrayItem } from '../../../test/randomness'; import { mockStorage } from '../../../test/mock-storage'; import { createEvent, fireEvent } from '@testing-library/dom'; import { EventType } from '@testing-library/dom/types/events'; import { SpoilerType } from '../../../types/booru-object'; import { beforeEach } from 'vitest'; describe('Image utils', () => { const hiddenClass = 'hidden'; const spoilerOverlayClass = 'js-spoiler-info-overlay'; const serveHidpiStorageKey = 'serve_hidpi'; const mockSpoilerReason = 'Mock reason'; const mockSpoilerUri = '/images/tagblocked.svg'; const mockImageUri = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; const getMockImageSizeUrls = (extension: string) => ({ thumb: `https://example.com/thumb.${extension}`, small: `https://example.com/small.${extension}`, medium: `https://example.com/medium.${extension}`, large: `https://example.com/large.${extension}`, }); type ImageSize = keyof ReturnType; const PossibleImageSizes: ImageSize[] = ['thumb', 'small', 'medium', 'large']; const applyMockDataAttributes = (element: HTMLElement, extension: string, size?: ImageSize) => { const mockSize = size || getRandomArrayItem(PossibleImageSizes); const mockSizeUrls = getMockImageSizeUrls(extension); element.setAttribute('data-size', mockSize); element.setAttribute('data-uris', JSON.stringify(mockSizeUrls)); return { mockSize, mockSizeUrls }; }; const createMockSpoilerOverlay = () => { const mockSpoilerOverlay = document.createElement('div'); mockSpoilerOverlay.classList.add(spoilerOverlayClass); return mockSpoilerOverlay; }; const createMockElementWithPicture = (extension: string, size?: ImageSize) => { const mockElement = document.createElement('div'); const { mockSizeUrls, mockSize } = applyMockDataAttributes(mockElement, extension, size); const mockPicture = document.createElement('picture'); mockElement.appendChild(mockPicture); const mockSizeImage = new Image(); mockPicture.appendChild(mockSizeImage); const mockSpoilerOverlay = createMockSpoilerOverlay(); mockElement.appendChild(mockSpoilerOverlay); return { mockElement, mockPicture, mockSize, mockSizeImage, mockSizeUrls, mockSpoilerOverlay, }; }; const imageFilteredClass = 'image-filtered'; const imageShowClass = 'image-show'; const spoilerPendingClass = 'spoiler-pending'; const createImageFilteredElement = (mockElement: HTMLDivElement) => { const mockFilteredImageElement = document.createElement('div'); mockFilteredImageElement.classList.add(imageFilteredClass); mockElement.appendChild(mockFilteredImageElement); return { mockFilteredImageElement }; }; const createImageShowElement = (mockElement: HTMLDivElement) => { const mockShowElement = document.createElement('div'); mockShowElement.classList.add(imageShowClass); mockShowElement.classList.add(hiddenClass); mockElement.appendChild(mockShowElement); return { mockShowElement }; }; describe('showThumb', () => { let mockServeHidpiValue: string | null = null; mockStorage({ getItem(key: string) { if (key !== serveHidpiStorageKey) return null; return mockServeHidpiValue; }, }); beforeEach(() => { mockServeHidpiValue = null; }); describe('video thumbnail', () => { type CreateMockElementsOptions = { extension: string; videoClasses?: string[]; imgClasses?: string[]; } const createMockElements = ({ videoClasses, imgClasses, extension }: CreateMockElementsOptions) => { const mockElement = document.createElement('div'); const { mockSize, mockSizeUrls } = applyMockDataAttributes(mockElement, extension); const mockImage = new Image(); mockImage.src = mockImageUri; if (imgClasses) { imgClasses.forEach(videoClass => { mockImage.classList.add(videoClass); }); } mockElement.appendChild(mockImage); const mockVideo = document.createElement('video'); if (videoClasses) { videoClasses.forEach(videoClass => { mockVideo.classList.add(videoClass); }); } mockElement.appendChild(mockVideo); const playSpy = vi.spyOn(mockVideo, 'play').mockReturnValue(Promise.resolve()); const mockSpoilerOverlay = createMockSpoilerOverlay(); mockElement.appendChild(mockSpoilerOverlay); return { mockElement, mockImage, mockSize, mockSizeUrls, mockSpoilerOverlay, mockVideo, playSpy, }; }; it('should hide the img element and show the video instead if no picture element is present', () => { const { mockElement, mockImage, playSpy, mockVideo, mockSize, mockSizeUrls, mockSpoilerOverlay, } = createMockElements({ extension: 'webm', videoClasses: ['hidden'], }); const result = showThumb(mockElement); expect(mockImage).to.have.class(hiddenClass); expect(mockVideo.children).toHaveLength(2); const webmSourceElement = mockVideo.children[0]; const webmSource = mockSizeUrls[mockSize]; expect(webmSourceElement.nodeName).toEqual('SOURCE'); expect(webmSourceElement.getAttribute('type')).toEqual('video/webm'); expect(webmSourceElement.getAttribute('src')).toEqual(webmSource); const mp4SourceElement = mockVideo.children[1]; expect(mp4SourceElement.nodeName).toEqual('SOURCE'); expect(mp4SourceElement.getAttribute('type')).toEqual('video/mp4'); expect(mp4SourceElement.getAttribute('src')).toEqual(webmSource.replace('webm', 'mp4')); expect(mockVideo).not.to.have.class(hiddenClass); expect(playSpy).toHaveBeenCalledTimes(1); expect(mockSpoilerOverlay).to.have.class(hiddenClass); expect(result).toBe(true); }); ['data-size', 'data-uris'].forEach(missingAttributeName => { it(`should return early if the ${missingAttributeName} attribute is missing`, () => { const { mockElement } = createMockElements({ extension: 'webm', }); const jsonParseSpy = vi.spyOn(JSON, 'parse'); mockElement.removeAttribute(missingAttributeName); try { const result = showThumb(mockElement); expect(result).toBe(false); expect(jsonParseSpy).not.toHaveBeenCalled(); } finally { jsonParseSpy.mockRestore(); } }); }); it('should return early if there is no video element', () => { const { mockElement, mockVideo, playSpy } = createMockElements({ extension: 'webm', }); mockElement.removeChild(mockVideo); const result = showThumb(mockElement); expect(result).toBe(false); expect(playSpy).not.toHaveBeenCalled(); }); it('should return early if img element is missing', () => { const { mockElement, mockImage, playSpy } = createMockElements({ extension: 'webm', imgClasses: ['hidden'], }); mockElement.removeChild(mockImage); const result = showThumb(mockElement); expect(result).toBe(false); expect(playSpy).not.toHaveBeenCalled(); }); it('should return early if img element already has the hidden class', () => { const { mockElement, playSpy } = createMockElements({ extension: 'webm', imgClasses: ['hidden'], }); const result = showThumb(mockElement); expect(result).toBe(false); expect(playSpy).not.toHaveBeenCalled(); }); }); it('should show the correct thumbnail image for jpg extension', () => { const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay, } = createMockElementWithPicture('jpg'); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); expect(mockSizeImage.srcset).toBe(''); expect(mockSpoilerOverlay).to.have.class(hiddenClass); expect(result).toBe(true); }); it('should show the correct thumbnail image for gif extension', () => { const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay, } = createMockElementWithPicture('gif'); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); expect(mockSizeImage.srcset).toBe(''); expect(mockSpoilerOverlay).to.have.class(hiddenClass); expect(result).toBe(true); }); it('should show the correct thumbnail image for webm extension', () => { const { mockElement, mockSpoilerOverlay, mockSizeImage, mockSizeUrls, mockSize, } = createMockElementWithPicture('webm'); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize].replace('webm', 'gif')); expect(mockSizeImage.srcset).toBe(''); expect(mockSpoilerOverlay).not.to.have.class(hiddenClass); expect(mockSpoilerOverlay).to.have.text('WebM'); expect(result).toBe(true); }); describe('high DPI srcset handling', () => { beforeEach(() => { mockServeHidpiValue = 'true'; }); const checkSrcsetAttribute = (size: ImageSize, x2size: ImageSize) => { const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay, } = createMockElementWithPicture('jpg', size); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[size]); expect(mockSizeImage.srcset).toContain(`${mockSizeUrls[size]} 1x`); expect(mockSizeImage.srcset).toContain(`${mockSizeUrls[x2size]} 2x`); expect(mockSpoilerOverlay).to.have.class(hiddenClass); return result; }; it('should set correct srcset on img if thumbUri is NOT a gif at small size', () => { const result = checkSrcsetAttribute('small', 'medium'); expect(result).toBe(true); }); it('should set correct srcset on img if thumbUri is NOT a gif at medium size', () => { const result = checkSrcsetAttribute('medium', 'large'); expect(result).toBe(true); }); it('should NOT set srcset on img if thumbUri is a gif at small size', () => { const mockSize = 'small'; const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay, } = createMockElementWithPicture('gif', mockSize); const result = showThumb(mockElement); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); expect(mockSizeImage.srcset).toBe(''); expect(mockSpoilerOverlay).to.have.class(hiddenClass); expect(result).toBe(true); }); }); it('should return false if img cannot be found', () => { const { mockElement, mockPicture, mockSizeImage } = createMockElementWithPicture('jpg'); mockPicture.removeChild(mockSizeImage); const result = showThumb(mockElement); expect(result).toBe(false); }); it('should return false if img source already matches thumbUri', () => { const { mockElement, mockSizeImage, mockSizeUrls, mockSize, } = createMockElementWithPicture('jpg'); mockSizeImage.src = mockSizeUrls[mockSize]; const result = showThumb(mockElement); expect(result).toBe(false); }); it('should return false if overlay is missing', () => { const { mockElement, mockSpoilerOverlay } = createMockElementWithPicture('jpg'); mockSpoilerOverlay.parentNode?.removeChild(mockSpoilerOverlay); const result = showThumb(mockElement); expect(result).toBe(false); }); }); describe('showBlock', () => { it('should hide the filtered image element and show the image', () => { const mockElement = document.createElement('div'); const { mockFilteredImageElement } = createImageFilteredElement(mockElement); const { mockShowElement } = createImageShowElement(mockElement); showBlock(mockElement); expect(mockFilteredImageElement).to.have.class(hiddenClass); expect(mockShowElement).not.to.have.class(hiddenClass); expect(mockShowElement).to.have.class(spoilerPendingClass); }); it('should not throw if image-filtered element is missing', () => { const mockElement = document.createElement('div'); createImageShowElement(mockElement); expect(() => showBlock(mockElement)).not.toThrow(); }); it('should not throw if image-show element is missing', () => { const mockElement = document.createElement('div'); createImageFilteredElement(mockElement); expect(() => showBlock(mockElement)).not.toThrow(); }); }); describe('hideThumb', () => { describe('hideVideoThumb', () => { it('should return early if picture AND video elements are missing', () => { const mockElement = document.createElement('div'); const querySelectorSpy = vi.spyOn(mockElement, 'querySelector'); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); try { expect(querySelectorSpy).toHaveBeenCalledTimes(2); expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture'); expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video'); } finally { querySelectorSpy.mockRestore(); } }); it('should return early if picture and img elements are missing BUT video element is present', () => { const mockElement = document.createElement('div'); const mockVideo = document.createElement('video'); mockElement.appendChild(mockVideo); const pauseSpy = vi.spyOn(mockVideo, 'pause').mockReturnValue(undefined); const querySelectorSpy = vi.spyOn(mockElement, 'querySelector'); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); try { expect(querySelectorSpy).toHaveBeenCalledTimes(4); expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture'); expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video'); expect(querySelectorSpy).toHaveBeenNthCalledWith(3, 'img'); expect(querySelectorSpy).toHaveBeenNthCalledWith(4, `.${spoilerOverlayClass}`); expect(mockVideo).not.to.have.class(hiddenClass); } finally { querySelectorSpy.mockRestore(); pauseSpy.mockRestore(); } }); it('should hide video thumbnail if picture element is missing BUT video element is present', () => { const mockElement = document.createElement('div'); const mockVideo = document.createElement('video'); mockElement.appendChild(mockVideo); const pauseSpy = vi.spyOn(mockVideo, 'pause').mockReturnValue(undefined); const mockImage = document.createElement('img'); mockImage.classList.add(hiddenClass); mockElement.appendChild(mockImage); const mockOverlay = document.createElement('span'); mockOverlay.classList.add(spoilerOverlayClass, hiddenClass); mockElement.appendChild(mockOverlay); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); try { expect(mockImage).not.to.have.class(hiddenClass); expect(mockImage).to.have.attribute('src', mockSpoilerUri); expect(mockOverlay).to.have.text(mockSpoilerReason); expect(mockVideo).not.to.have.descendants('*'); expect(mockVideo).to.have.class(hiddenClass); expect(pauseSpy).toHaveBeenCalled(); } finally { pauseSpy.mockRestore(); } }); }); it('should return early if picture element is present AND img element is missing', () => { const mockElement = document.createElement('div'); const mockPicture = document.createElement('picture'); mockElement.appendChild(mockPicture); const imgQuerySelectorSpy = vi.spyOn(mockElement, 'querySelector'); const pictureQuerySelectorSpy = vi.spyOn(mockPicture, 'querySelector'); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); try { expect(imgQuerySelectorSpy).toHaveBeenCalledTimes(2); expect(pictureQuerySelectorSpy).toHaveBeenCalledTimes(1); expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'picture'); expect(pictureQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'img'); expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(2, `.${spoilerOverlayClass}`); } finally { imgQuerySelectorSpy.mockRestore(); pictureQuerySelectorSpy.mockRestore(); } }); it('should hide img thumbnail if picture element is present AND img element is present', () => { const mockElement = document.createElement('div'); const mockPicture = document.createElement('picture'); mockElement.appendChild(mockPicture); const mockImage = document.createElement('img'); mockPicture.appendChild(mockImage); const mockOverlay = document.createElement('span'); mockOverlay.classList.add(spoilerOverlayClass, hiddenClass); mockElement.appendChild(mockOverlay); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); expect(mockImage).to.have.attribute('srcset', ''); expect(mockImage).to.have.attribute('src', mockSpoilerUri); expect(mockOverlay).to.contain.html(mockSpoilerReason); expect(mockOverlay).not.to.have.class(hiddenClass); }); }); describe('spoilerThumb', () => { const testSpoilerThumb = (handlers?: [EventType, EventType]) => { const { mockElement, mockSpoilerOverlay, mockSizeImage } = createMockElementWithPicture('jpg'); const addEventListenerSpy = vi.spyOn(mockElement, 'addEventListener'); spoilerThumb(mockElement, mockSpoilerUri, mockSpoilerReason); // Element should be hidden by the call expect(mockSizeImage).to.have.attribute('src', mockSpoilerUri); expect(mockSpoilerOverlay).not.to.have.class(hiddenClass); expect(mockSpoilerOverlay).to.contain.html(mockSpoilerReason); // If addEventListener calls are not expected, bail if (!handlers) { expect(addEventListenerSpy).not.toHaveBeenCalled(); return; } const [firstHandler, secondHandler] = handlers; // Event listeners should be attached to correct events expect(addEventListenerSpy).toHaveBeenCalledTimes(2); expect(addEventListenerSpy.mock.calls[0][0]).toBe(firstHandler.toLowerCase()); expect(addEventListenerSpy.mock.calls[1][0]).toBe(secondHandler.toLowerCase()); // Clicking once should reveal the image and hide spoiler elements let clickEvent = createEvent[firstHandler](mockElement); fireEvent(mockElement, clickEvent); if (firstHandler === 'click') { expect(clickEvent.defaultPrevented).toBe(true); } expect(mockSizeImage).not.to.have.attribute('src', mockSpoilerUri); expect(mockSpoilerOverlay).to.have.class(hiddenClass); if (firstHandler === 'click') { // Second attempt to click a shown spoiler should not cause default prevention clickEvent = createEvent.click(mockElement); fireEvent(mockElement, clickEvent); expect(clickEvent.defaultPrevented).toBe(false); } // Moving the mouse away should hide the image and show the overlay again const mouseLeaveEvent = createEvent.mouseLeave(mockElement); fireEvent(mockElement, mouseLeaveEvent); expect(mockSizeImage).to.have.attribute('src', mockSpoilerUri); expect(mockSpoilerOverlay).not.to.have.class(hiddenClass); expect(mockSpoilerOverlay).to.contain.html(mockSpoilerReason); }; let lastSpoilerType: SpoilerType; beforeEach(() => { lastSpoilerType = window.booru.spoilerType; }); afterEach(() => { window.booru.spoilerType = lastSpoilerType; }); it('should add click and mouseleave handlers for click spoiler type', () => { window.booru.spoilerType = 'click'; expect.hasAssertions(); testSpoilerThumb(['click', 'mouseLeave']); }); it('should add mouseenter and mouseleave handlers for hover spoiler type', () => { window.booru.spoilerType = 'hover'; expect.hasAssertions(); testSpoilerThumb(['mouseEnter', 'mouseLeave']); }); it('should not add event handlers for off spoiler type', () => { window.booru.spoilerType = 'off'; expect.hasAssertions(); testSpoilerThumb(); }); it('should not add event handlers for static spoiler type', () => { window.booru.spoilerType = 'static'; expect.hasAssertions(); testSpoilerThumb(); }); }); describe('spoilerBlock', () => { const filterExplanationClass = 'filter-explanation'; const createFilteredImageElement = () => { const mockImageFiltered = document.createElement('div'); mockImageFiltered.classList.add(imageFilteredClass, hiddenClass); const mockImage = new Image(); mockImage.src = mockImageUri; mockImageFiltered.appendChild(mockImage); return { mockImageFiltered, mockImage }; }; const createMockElement = (appendImageShow = true, appendImageFiltered = true) => { const mockElement = document.createElement('div'); const { mockImageFiltered, mockImage } = createFilteredImageElement(); if (appendImageFiltered) mockElement.appendChild(mockImageFiltered); const mockExplanation = document.createElement('span'); mockExplanation.classList.add(filterExplanationClass); mockElement.appendChild(mockExplanation); const mockImageShow = document.createElement('div'); mockImageShow.classList.add(imageShowClass); if (appendImageShow) mockElement.appendChild(mockImageShow); return { mockElement, mockImage, mockExplanation, mockImageShow, mockImageFiltered }; }; it('should not throw if image element is missing', () => { const mockElement = document.createElement('div'); const { mockImageFiltered, mockImage } = createFilteredImageElement(); mockImage.parentNode?.removeChild(mockImage); mockElement.appendChild(mockImageFiltered); expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow(); }); it('should update the elements with the parameters and set classes if image element is found', () => { const { mockElement, mockImage, mockExplanation, mockImageShow, mockImageFiltered } = createMockElement(); spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason); expect(mockImage).to.have.attribute('src', mockSpoilerUri); expect(mockExplanation).to.contain.html(mockSpoilerReason); expect(mockImageShow).to.have.class(hiddenClass); expect(mockImageFiltered).not.to.have.class(hiddenClass); }); it('should not throw if image-filtered element is missing', () => { const { mockElement } = createMockElement(true, false); expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow(); }); it('should not throw if image-show element is missing', () => { const { mockElement } = createMockElement(false, true); expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow(); }); }); });