diff --git a/src/common/hooks/__tests__/useCacheResponse.test.ts b/src/common/hooks/__tests__/useCacheResponse.test.ts new file mode 100644 index 0000000000..e6ad169f9a --- /dev/null +++ b/src/common/hooks/__tests__/useCacheResponse.test.ts @@ -0,0 +1,42 @@ +import { renderHook } from '@testing-library/react'; +import useCacheResponse from '../useCacheResponse'; + +describe('useCacheResponse', () => { + it('stores and retrieves cached data', () => { + const { result } = renderHook(() => useCacheResponse()); + const [retrieveCache, createCache] = result.current; + + createCache('testKey', { data: 'hello' }); + expect(retrieveCache('testKey')).toEqual({ data: 'hello' }); + }); + + it('returns null for a cache key that does not exist', () => { + const { result } = renderHook(() => useCacheResponse()); + const [retrieveCache] = result.current; + + expect(retrieveCache('nonExistentKey')).toBeNull(); + }); + + it('overwrites existing cache entries', () => { + const { result } = renderHook(() => useCacheResponse()); + const [retrieveCache, createCache] = result.current; + + createCache('key', 'first'); + createCache('key', 'second'); + expect(retrieveCache('key')).toBe('second'); + }); + + it('supports different data types', () => { + const { result } = renderHook(() => useCacheResponse()); + const [retrieveCache, createCache] = result.current; + + createCache('number', 42); + createCache('array', [1, 2, 3]); + createCache('null', null); + + expect(retrieveCache('number')).toBe(42); + expect(retrieveCache('array')).toEqual([1, 2, 3]); + // null is falsy, so the hook returns null for it too + expect(retrieveCache('null')).toBeNull(); + }); +}); diff --git a/src/common/hooks/__tests__/useContributors.test.js b/src/common/hooks/__tests__/useContributors.test.js new file mode 100644 index 0000000000..b6cf94303d --- /dev/null +++ b/src/common/hooks/__tests__/useContributors.test.js @@ -0,0 +1,78 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useContributors from '../useContributors'; + +beforeEach(() => { + global.fetch = jest.fn(); + process.env.REACT_APP_PLAY_API_URL = 'https://api.test.com'; +}); + +afterEach(() => { + jest.restoreAllMocks(); + delete process.env.REACT_APP_PLAY_API_URL; +}); + +const mockContributors = [ + { login: 'user1', type: 'User', contributions: 50 }, + { login: 'dependabot', type: 'Bot', contributions: 100 }, + { login: 'user2', type: 'User', contributions: 30 }, + { login: 'user3', type: 'User', contributions: 80 } +]; + +describe('useContributors', () => { + it('fetches contributors and filters out bots', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockContributors) + }); + + const { result } = renderHook(() => useContributors(false)); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toHaveLength(3); + expect(result.current.data.every((c) => c.type !== 'Bot')).toBe(true); + expect(result.current.error).toBeUndefined(); + }); + + it('sorts contributors by contributions when sorted=true', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockContributors) + }); + + const { result } = renderHook(() => useContributors(true)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const contributions = result.current.data.map((c) => c.contributions); + expect(contributions).toEqual([80, 50, 30]); // descending order, bot excluded + }); + + it('sets error on fetch failure', async () => { + const mockError = new Error('Network error'); + global.fetch.mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useContributors(false)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(mockError); + expect(result.current.data).toBeUndefined(); + }); + + it('fetches from the correct API URL', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve([]) + }); + + renderHook(() => useContributors(false)); + + expect(global.fetch).toHaveBeenCalledWith('https://api.test.com/react-play/contributors'); + }); +}); diff --git a/src/common/hooks/__tests__/useFeaturedPlays.test.js b/src/common/hooks/__tests__/useFeaturedPlays.test.js new file mode 100644 index 0000000000..1e325f2c4c --- /dev/null +++ b/src/common/hooks/__tests__/useFeaturedPlays.test.js @@ -0,0 +1,53 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useFeaturedPlays from '../useFeaturedPlays'; +import { submit } from 'common/services/request'; + +// Mock the services/request module +jest.mock('common/services/request', () => ({ + submit: jest.fn() +})); + +jest.mock('common/services/request/query/fetch-plays-filter', () => ({ + FetchPlaysFilter: { + getAllFeaturedPlays: jest.fn(() => 'mock-query') + } +})); + +describe('useFeaturedPlays', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns featured plays data on success', async () => { + const mockPlays = [ + { id: 1, name: 'Play 1' }, + { id: 2, name: 'Play 2' } + ]; + submit.mockResolvedValueOnce(mockPlays); + + const { result } = renderHook(() => useFeaturedPlays()); + + // [loading, error, data] + expect(result.current[0]).toBe(true); // loading + + await waitFor(() => { + expect(result.current[0]).toBe(false); // loading done + }); + + expect(result.current[1]).toBeNull(); // no error + expect(result.current[2]).toEqual(mockPlays); // data + }); + + it('sets error on failure', async () => { + const mockError = { message: 'GraphQL error' }; + submit.mockRejectedValueOnce([mockError]); + + const { result } = renderHook(() => useFeaturedPlays()); + + await waitFor(() => { + expect(result.current[0]).toBe(false); + }); + + expect(result.current[1]).toEqual(mockError); // error + }); +}); diff --git a/src/common/hooks/__tests__/useFetch.test.js b/src/common/hooks/__tests__/useFetch.test.js new file mode 100644 index 0000000000..6364a36938 --- /dev/null +++ b/src/common/hooks/__tests__/useFetch.test.js @@ -0,0 +1,58 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useFetch from '../useFetch'; + +// Mock global fetch +beforeEach(() => { + global.fetch = jest.fn(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('useFetch', () => { + it('returns data on successful fetch', async () => { + const mockData = [{ id: 1, name: 'Play 1' }]; + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockData) + }); + + const { result } = renderHook(() => useFetch('https://api.example.com/data')); + + // Initially loading + expect(result.current.loading).toBe(true); + expect(result.current.data).toEqual([]); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockData); + expect(result.current.error).toBeNull(); + }); + + it('sets error on fetch failure', async () => { + const mockError = new Error('Network error'); + global.fetch.mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useFetch('https://api.example.com/fail')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe(mockError); + expect(result.current.data).toEqual([]); + }); + + it('passes options to fetch', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({}) + }); + + const options = { method: 'POST', body: JSON.stringify({ test: true }) }; + renderHook(() => useFetch('https://api.example.com/data', options)); + + expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data', options); + }); +}); diff --git a/src/common/hooks/__tests__/useGitHub.test.js b/src/common/hooks/__tests__/useGitHub.test.js new file mode 100644 index 0000000000..383ee469fd --- /dev/null +++ b/src/common/hooks/__tests__/useGitHub.test.js @@ -0,0 +1,49 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useGitHub from '../useGitHub'; + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('useGitHub', () => { + it('fetches GitHub user data successfully', async () => { + const mockUser = { + login: 'octocat', + name: 'The Octocat', + public_repos: 8 + }; + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockUser) + }); + + const { result } = renderHook(() => useGitHub('octocat')); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(mockUser); + expect(result.current.error).toBeUndefined(); + expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/users/octocat'); + }); + + it('handles fetch errors', async () => { + const mockError = new Error('Rate limited'); + global.fetch.mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useGitHub('unknown-user')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(mockError); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/src/common/hooks/__tests__/useLikePlays.test.js b/src/common/hooks/__tests__/useLikePlays.test.js new file mode 100644 index 0000000000..8e819d312f --- /dev/null +++ b/src/common/hooks/__tests__/useLikePlays.test.js @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react'; +import useLikePlays from '../useLikePlays'; +import { submit } from 'common/services/request'; + +jest.mock('common/services/request', () => ({ + submit: jest.fn() +})); + +jest.mock('common/services/request/query/like-play', () => ({ + likeIndividualPlay: jest.fn((obj) => ({ type: 'LIKE', ...obj })), + unlikeIndividualPlay: jest.fn((obj) => ({ type: 'UNLIKE', ...obj })) +})); + +describe('useLikePlays', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('likePlay calls submit and resolves on success', async () => { + const mockResponse = { id: 1, liked: true }; + submit.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useLikePlays()); + const response = await result.current.likePlay({ play_id: 'abc' }); + + expect(response).toEqual(mockResponse); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('likePlay rejects on submit failure', async () => { + const mockError = new Error('Mutation failed'); + submit.mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useLikePlays()); + + await expect(result.current.likePlay({ play_id: 'abc' })).rejects.toThrow('Mutation failed'); + }); + + it('unLikePlay calls submit and resolves on success', async () => { + const mockResponse = { id: 1, liked: false }; + submit.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useLikePlays()); + const response = await result.current.unLikePlay({ play_id: 'abc' }); + + expect(response).toEqual(mockResponse); + }); + + it('unLikePlay rejects on submit failure', async () => { + submit.mockRejectedValueOnce(new Error('Delete failed')); + + const { result } = renderHook(() => useLikePlays()); + + await expect(result.current.unLikePlay({ play_id: 'abc' })).rejects.toThrow('Delete failed'); + }); +}); diff --git a/src/common/hooks/__tests__/useLocalStorage.test.js b/src/common/hooks/__tests__/useLocalStorage.test.js new file mode 100644 index 0000000000..cd74e9837c --- /dev/null +++ b/src/common/hooks/__tests__/useLocalStorage.test.js @@ -0,0 +1,81 @@ +import { renderHook, act } from '@testing-library/react'; +import useLocalStorage from '../useLocalStorage'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: jest.fn((key) => store[key] || null), + setItem: jest.fn((key, value) => { + store[key] = value; + }), + clear: () => { + store = {}; + } + }; +})(); + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorageMock.clear(); + jest.clearAllMocks(); + }); + + it('returns initial value when localStorage is empty', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'default')); + expect(result.current[0]).toBe('default'); + }); + + it('reads existing value from localStorage', () => { + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify('stored-value')); + const { result } = renderHook(() => useLocalStorage('testKey', 'default')); + expect(result.current[0]).toBe('stored-value'); + }); + + it('updates localStorage when setValue is called', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'initial')); + + act(() => { + result.current[1]('new-value'); + }); + + expect(result.current[0]).toBe('new-value'); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'testKey', + JSON.stringify('new-value') + ); + }); + + it('supports function updater pattern', () => { + const { result } = renderHook(() => useLocalStorage('counter', 0)); + + act(() => { + result.current[1]((prev) => prev + 1); + }); + + expect(result.current[0]).toBe(1); + }); + + it('handles JSON parse errors gracefully', () => { + localStorageMock.getItem.mockReturnValueOnce('invalid-json{{{'); + const { result } = renderHook(() => useLocalStorage('badKey', 'fallback')); + expect(result.current[0]).toBe('fallback'); + }); + + it('stores objects correctly', () => { + const { result } = renderHook(() => useLocalStorage('objKey', {})); + + act(() => { + result.current[1]({ name: 'test', count: 5 }); + }); + + expect(result.current[0]).toEqual({ name: 'test', count: 5 }); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'objKey', + JSON.stringify({ name: 'test', count: 5 }) + ); + }); +}); diff --git a/src/common/search/__tests__/search-helper.test.js b/src/common/search/__tests__/search-helper.test.js new file mode 100644 index 0000000000..a4eb67a2e3 --- /dev/null +++ b/src/common/search/__tests__/search-helper.test.js @@ -0,0 +1,93 @@ +import { ParseQuery, QueryExtractValue, QueryDBTranslator } from '../search-helper'; + +describe('ParseQuery', () => { + it('parses a query string into an object', () => { + expect(ParseQuery('?levels=1&tags=react')).toEqual({ + levels: '1', + tags: 'react' + }); + }); + + it('handles query strings without leading ?', () => { + expect(ParseQuery('levels=1&tags=react')).toEqual({ + levels: '1', + tags: 'react' + }); + }); + + it('decodes URI-encoded values', () => { + expect(ParseQuery('text=hello%20world')).toEqual({ + text: 'hello world' + }); + }); + + it('handles empty value for a key', () => { + expect(ParseQuery('key=')).toEqual({ key: '' }); + }); + + it('returns undefined for falsy input', () => { + expect(ParseQuery('')).toBeUndefined(); + expect(ParseQuery(null)).toBeUndefined(); + expect(ParseQuery(undefined)).toBeUndefined(); + }); +}); + +describe('QueryExtractValue', () => { + it('splits comma-separated values into arrays', () => { + const query = { levels: '1,2,3', tags: 'react,javascript' }; + expect(QueryExtractValue(query)).toEqual({ + levels: ['1', '2', '3'], + tags: ['react', 'javascript'] + }); + }); + + it('wraps single values in an array', () => { + expect(QueryExtractValue({ levels: '1' })).toEqual({ + levels: ['1'] + }); + }); +}); + +describe('QueryDBTranslator', () => { + it('translates datafield keys to dbfield keys', () => { + const query = { levels: '1,2' }; + const result = QueryDBTranslator(query); + // levels maps to level_id per FIELD_TEMPLATE + expect(result).toHaveProperty('level_id', ['1', '2']); + }); + + it('keeps datafield key when no dbfield mapping exists', () => { + const query = { tags: 'react-tag-id' }; + const result = QueryDBTranslator(query); + // tags has no dbfield in FIELD_TEMPLATE + expect(result).toHaveProperty('tags', ['react-tag-id']); + }); + + it('handles text field specially as a single string', () => { + const query = { text: 'my search' }; + const result = QueryDBTranslator(query); + expect(result.text).toBe('my search'); + }); + + it('ignores query keys not in FIELD_TEMPLATE', () => { + const query = { unknownField: 'value' }; + const result = QueryDBTranslator(query); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('handles combined query with multiple fields', () => { + const query = { + levels: '1,2', + creators: 'user-id-1', + languages: 'js', + text: 'todo app' + }; + const result = QueryDBTranslator(query); + expect(result).toEqual({ + level_id: ['1', '2'], + owner_user_id: ['user-id-1'], + language: ['js'], + text: 'todo app' + }); + }); +}); diff --git a/src/common/services/__tests__/plays.test.js b/src/common/services/__tests__/plays.test.js new file mode 100644 index 0000000000..ec4789912a --- /dev/null +++ b/src/common/services/__tests__/plays.test.js @@ -0,0 +1,99 @@ +import { deleteATag, getPlaysByFilter } from '../plays'; +import { submit } from '../request'; + +jest.mock('../request', () => ({ + submit: jest.fn(), + submitMutation: jest.fn() +})); + +jest.mock('../request/query', () => ({ + deleteATagQuery: jest.fn((params) => ({ type: 'DELETE_TAG', ...params })) +})); + +jest.mock('../request/query/play', () => ({ + associatePlayWithTagQuery: 'ASSOCIATE_TAG_QUERY', + createPlayQuery: 'CREATE_PLAY_QUERY' +})); + +jest.mock('common/services/request/query/fetch-plays', () => ({ + FetchPlaysByFilter: jest.fn(() => 'FETCH_PLAYS_PAYLOAD') +})); + +jest.mock('common/services/string', () => ({ + toTitleCaseTrimmed: jest.fn((str) => str.replace(/\s/g, '')), + toKebabCase: jest.fn((str) => str.toLowerCase().replace(/\s+/g, '-')), + toSlug: jest.fn((str) => str.toLowerCase().replace(/\s+/g, '-')) +})); + +describe('deleteATag', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns an array of deletion promises for removed tags', () => { + submit.mockReturnValue(Promise.resolve()); + + const actualTags = [ + { id: 'tag-1', name: 'React' }, + { id: 'tag-2', name: 'JavaScript' }, + { id: 'tag-3', name: 'CSS' } + ]; + const newTags = [{ id: 'tag-1', name: 'React' }]; // tag-2 and tag-3 removed + + const result = deleteATag('play-1', actualTags, newTags); + + expect(result).toHaveLength(2); + expect(submit).toHaveBeenCalledTimes(2); + }); + + it('returns empty array when no tags are removed', () => { + const actualTags = [{ id: 'tag-1', name: 'React' }]; + const newTags = [{ id: 'tag-1', name: 'React' }]; + + const result = deleteATag('play-1', actualTags, newTags); + + expect(result).toHaveLength(0); + expect(submit).not.toHaveBeenCalled(); + }); + + it('returns empty array when there are no actual tags', () => { + const result = deleteATag('play-1', [], [{ id: 'tag-1' }]); + expect(result).toHaveLength(0); + }); +}); + +describe('getPlaysByFilter', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns plays with title_name appended', async () => { + const mockPlays = [ + { name: 'todo app', play_tags: [] }, + { name: 'calculator', play_tags: [] } + ]; + submit.mockResolvedValueOnce(mockPlays); + + const result = await getPlaysByFilter(null, 'newest'); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('title_name'); + expect(result[1]).toHaveProperty('title_name'); + }); + + it('filters by tags when tags filter is provided', async () => { + const mockPlays = [ + { name: 'play1', play_tags: [{ tag_id: 'tag-1' }] }, + { name: 'play2', play_tags: [{ tag_id: 'tag-2' }] }, + { name: 'play3', play_tags: [{ tag_id: 'tag-3' }] } + ]; + submit.mockResolvedValueOnce(mockPlays); + + const filter = { tags: ['tag-1', 'tag-3'] }; + const result = await getPlaysByFilter(filter, 'newest'); + + // Only play1 and play3 match the tags + expect(result).toHaveLength(2); + expect(result.map((p) => p.name)).toEqual(['play1', 'play3']); + }); +}); diff --git a/src/common/utils/__tests__/commonUtils.test.js b/src/common/utils/__tests__/commonUtils.test.js new file mode 100644 index 0000000000..78550cbd2d --- /dev/null +++ b/src/common/utils/__tests__/commonUtils.test.js @@ -0,0 +1,68 @@ +import { compareTextValue, getProdUrl, formatDate } from '../commonUtils'; +import countByProp from '../commonUtils'; + +describe('compareTextValue', () => { + it('returns true when string option contains search text (case-insensitive)', () => { + expect(compareTextValue('Hello World', 'hello')).toBe(true); + expect(compareTextValue('Hello World', 'WORLD')).toBe(true); + }); + + it('returns false when string option does not contain search text', () => { + expect(compareTextValue('Hello World', 'xyz')).toBe(false); + }); + + it('handles React element with props.children array', () => { + const element = { props: { children: ['icon', 'JavaScript'] } }; + expect(compareTextValue(element, 'java')).toBe(true); + expect(compareTextValue(element, 'python')).toBe(false); + }); +}); + +describe('getProdUrl', () => { + it('prepends the reactplay.io domain to a path', () => { + expect(getProdUrl('/plays/my-play')).toBe('https://reactplay.io/plays/my-play'); + }); + + it('handles root path', () => { + expect(getProdUrl('/')).toBe('https://reactplay.io/'); + }); + + it('handles empty string', () => { + expect(getProdUrl('')).toBe('https://reactplay.io'); + }); +}); + +describe('countByProp', () => { + const items = [ + { type: 'play', name: 'A' }, + { type: 'idea', name: 'B' }, + { type: 'play', name: 'C' }, + { type: 'play', name: 'D' } + ]; + + it('counts items matching the given key-value pair', () => { + expect(countByProp(items, 'type', 'play')).toBe(3); + expect(countByProp(items, 'type', 'idea')).toBe(1); + }); + + it('returns 0 when no items match', () => { + expect(countByProp(items, 'type', 'missing')).toBe(0); + }); + + it('returns 0 for an empty array', () => { + expect(countByProp([], 'type', 'play')).toBe(0); + }); +}); + +describe('formatDate', () => { + it('formats an ISO date string into "Joined DD Mon YYYY"', () => { + const result = formatDate('2023-06-15T10:00:00Z'); + expect(result).toMatch(/^Joined 15 \w+ 2023$/); + }); + + it('returns empty string for falsy input', () => { + expect(formatDate('')).toBe(''); + expect(formatDate(null)).toBe(''); + expect(formatDate(undefined)).toBe(''); + }); +}); diff --git a/src/common/utils/__tests__/coverImageUtil.test.js b/src/common/utils/__tests__/coverImageUtil.test.js new file mode 100644 index 0000000000..e643fa270e --- /dev/null +++ b/src/common/utils/__tests__/coverImageUtil.test.js @@ -0,0 +1,60 @@ +// We need to mock the dynamic import used inside coverImageUtil +// and also the fallback image import. +import { loadCoverImage } from '../coverImageUtil'; + +const MOCK_FALLBACK = 'fallback-image.png'; +const MOCK_COVER = 'cover-image.png'; + +jest.mock('images/play-fallback-cover.png', () => 'fallback-image.png', { virtual: true }); + +// Mock the dynamic import by overriding the loadImageForExtension behavior +// through mocking the entire module and re-implementing loadCoverImage with +// a controllable import mock. +let mockImport; + +jest.mock('../coverImageUtil', () => { + const { IMAGE_EXTENSIONS, FULFILLED_STATUS } = jest.requireActual('../utilsConstants'); + + return { + loadCoverImage: async (playSlug) => { + const imagePromises = IMAGE_EXTENSIONS.map((extension) => mockImport(playSlug, extension)); + const results = await Promise.allSettled(imagePromises); + const image = results.find( + (result) => result.status === FULFILLED_STATUS && result.value?.default + ); + + return image?.value.default || 'fallback-image.png'; + } + }; +}); + +describe('loadCoverImage', () => { + beforeEach(() => { + mockImport = jest.fn(); + }); + + it('returns the first successfully loaded image', async () => { + // png fails, jpg succeeds + mockImport + .mockRejectedValueOnce(new Error('not found')) // png + .mockResolvedValueOnce({ default: MOCK_COVER }) // jpg + .mockRejectedValueOnce(new Error('not found')); // jpeg + + const result = await loadCoverImage('my-play'); + expect(result).toBe(MOCK_COVER); + }); + + it('returns fallback image when all extensions fail', async () => { + mockImport.mockRejectedValue(new Error('not found')); + + const result = await loadCoverImage('missing-play'); + expect(result).toBe(MOCK_FALLBACK); + }); + + it('returns fallback when images resolve without default property', async () => { + mockImport.mockResolvedValue({ noDefault: true }); + + const result = await loadCoverImage('bad-module'); + expect(result).toBe(MOCK_FALLBACK); + }); +}); diff --git a/src/common/utils/__tests__/formatCount.test.ts b/src/common/utils/__tests__/formatCount.test.ts new file mode 100644 index 0000000000..83045626a9 --- /dev/null +++ b/src/common/utils/__tests__/formatCount.test.ts @@ -0,0 +1,45 @@ +import { formatDurationCount, formatViewCount } from '../formatCount'; + +describe('formatDurationCount', () => { + it('formats seconds-only durations as MM:SS', () => { + expect(formatDurationCount(0)).toBe('00:00'); + expect(formatDurationCount(5)).toBe('00:05'); + expect(formatDurationCount(59)).toBe('00:59'); + }); + + it('formats minute durations as MM:SS', () => { + expect(formatDurationCount(60)).toBe('01:00'); + expect(formatDurationCount(125)).toBe('02:05'); + expect(formatDurationCount(3599)).toBe('59:59'); + }); + + it('includes hours when duration >= 3600', () => { + expect(formatDurationCount(3600)).toBe('1:00:00'); + expect(formatDurationCount(3661)).toBe('1:01:01'); + expect(formatDurationCount(7384)).toBe('2:03:04'); + }); +}); + +describe('formatViewCount', () => { + it('returns the original string for counts below 1000', () => { + expect(formatViewCount('0')).toBe('0'); + expect(formatViewCount('999')).toBe('999'); + expect(formatViewCount('42')).toBe('42'); + }); + + it('formats thousands with K suffix', () => { + expect(formatViewCount('1000')).toBe('1.0K'); + expect(formatViewCount('1500')).toBe('1.5K'); + expect(formatViewCount('999999')).toBe('1000.0K'); + }); + + it('formats millions with M suffix', () => { + expect(formatViewCount('1000000')).toBe('1.0M'); + expect(formatViewCount('2500000')).toBe('2.5M'); + }); + + it('formats billions with B suffix', () => { + expect(formatViewCount('1000000000')).toBe('1.0B'); + expect(formatViewCount('3700000000')).toBe('3.7B'); + }); +});