From fbc3817efd7888fda20bb60d15e8173454752e8e Mon Sep 17 00:00:00 2001 From: Yiannis <1515939+styiannis@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:03:27 +0200 Subject: [PATCH] feat: utils/classes unit tests --- .../tests/utils/classes/BrowserCache.test.ts | 92 ++++++++++++++++ .../utils/classes/MediaDurationInfo.test.ts | 101 +++++++++++++++++ .../utils/classes/UpNextLoaderView.test.ts | 102 ++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 frontend/tests/utils/classes/BrowserCache.test.ts create mode 100644 frontend/tests/utils/classes/MediaDurationInfo.test.ts create mode 100644 frontend/tests/utils/classes/UpNextLoaderView.test.ts diff --git a/frontend/tests/utils/classes/BrowserCache.test.ts b/frontend/tests/utils/classes/BrowserCache.test.ts new file mode 100644 index 00000000..9665df21 --- /dev/null +++ b/frontend/tests/utils/classes/BrowserCache.test.ts @@ -0,0 +1,92 @@ +import { BrowserCache } from '../../../src/static/js/utils/classes/BrowserCache'; + +// Mocks for helpers used by BrowserCache +jest.mock('../../../src/static/js/utils/helpers/', () => ({ + logErrorAndReturnError: jest.fn((args: any[]) => ({ error: true, args })), + logWarningAndReturnError: jest.fn((args: any[]) => ({ warning: true, args })), +})); + +const { logErrorAndReturnError } = jest.requireMock('../../../src/static/js/utils/helpers/'); + +describe('utils/classes', () => { + describe('BrowserCache', () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + test('Returns error when prefix is missing', () => { + const cache = BrowserCache(undefined, 3600); + expect(cache).toEqual(expect.objectContaining({ error: true })); + expect(logErrorAndReturnError).toHaveBeenCalledWith(['Cache object prefix is required']); + }); + + test('Set and get returns stored primitive value before expiration', () => { + const cache = BrowserCache('prefix', 3600); + + if (cache instanceof Error) { + expect(cache instanceof Error).toBe(false); + return; + } + + expect(cache.set('foo', 'bar')).toBe(true); + expect(cache.get('foo')).toBe('bar'); + + // Ensure value serialized in localStorage with namespaced key + const raw = localStorage.getItem('prefix[foo]') as string; + const parsed = JSON.parse(raw); + expect(parsed.value).toBe('bar'); + expect(typeof parsed.expire).toBe('number'); + expect(parsed.expire).toBeGreaterThan(Date.now()); + }); + + test('Get returns null when expired', () => { + const cache = BrowserCache('prefix', 1); + + if (cache instanceof Error) { + expect(cache instanceof Error).toBe(false); + return; + } + + cache.set('exp', { a: 1 }); + + jest.useFakeTimers(); + jest.advanceTimersByTime(1_000); + + expect(cache.get('exp')).toBeNull(); + + jest.useRealTimers(); + }); + + test('Clear removes only keys for its prefix', () => { + const cacheA = BrowserCache('A', 3600); + const cacheB = BrowserCache('B', 3600); + + if (cacheA instanceof Error) { + expect(cacheA instanceof Error).toBe(false); + return; + } + + if (cacheB instanceof Error) { + expect(cacheB instanceof Error).toBe(false); + return; + } + + cacheA.set('x', 1); + cacheB.set('x', 2); + + expect(localStorage.getItem('A[x]')).toBeTruthy(); + expect(localStorage.getItem('B[x]')).toBeTruthy(); + + cacheA.clear(); + + expect(localStorage.getItem('A[x]')).toBeNull(); + expect(localStorage.getItem('B[x]')).toBeTruthy(); + + cacheB.clear(); + + expect(localStorage.getItem('A[x]')).toBeNull(); + expect(localStorage.getItem('B[x]')).toBeNull(); + }); + }); +}); diff --git a/frontend/tests/utils/classes/MediaDurationInfo.test.ts b/frontend/tests/utils/classes/MediaDurationInfo.test.ts new file mode 100644 index 00000000..70818388 --- /dev/null +++ b/frontend/tests/utils/classes/MediaDurationInfo.test.ts @@ -0,0 +1,101 @@ +import { MediaDurationInfo } from '../../../src/static/js/utils/classes/MediaDurationInfo'; + +describe('utils/classes', () => { + describe('MediaDurationInfo', () => { + test('Initializes via constructor when seconds is a positive integer (<= 59)', () => { + const mdi = new MediaDurationInfo(42); + expect(mdi.toString()).toBe('0:42'); + expect(mdi.ariaLabel()).toBe('42 seconds'); + expect(mdi.ISO8601()).toBe('P0Y0M0DT0H0M42S'); + }); + + test('Formats minutes and zero-pads seconds; no hours prefix under 60 minutes', () => { + const mdi = new MediaDurationInfo(); + mdi.update(5 * 60 + 7); + expect(mdi.toString()).toBe('5:07'); + expect(mdi.ariaLabel()).toBe('5 minutes, 7 seconds'); + expect(mdi.ISO8601()).toBe('P0Y0M0DT0H5M7S'); + }); + + test('Includes hours when duration >= 1 hour and zero-pads minutes when needed', () => { + const mdi = new MediaDurationInfo(); + mdi.update(1 * 3600 + 2 * 60 + 3); + expect(mdi.toString()).toBe('1:02:03'); + expect(mdi.ariaLabel()).toBe('1 hours, 2 minutes, 3 seconds'); + expect(mdi.ISO8601()).toBe('P0Y0M0DT1H2M3S'); + }); + + test('Accumulates hours when days are present (e.g., 1 day + 2:03:04 => 26:03:04)', () => { + const mdi = new MediaDurationInfo(); + const seconds = 1 * 86400 + 2 * 3600 + 3 * 60 + 4; // 1d 2:03:04 => 26:03:04 + mdi.update(seconds); + expect(mdi.toString()).toBe('26:03:04'); + expect(mdi.ariaLabel()).toBe('26 hours, 3 minutes, 4 seconds'); + expect(mdi.ISO8601()).toBe('P0Y0M0DT26H3M4S'); + }); + + test('Large durations: multiple days correctly mapped into hours', () => { + const mdi = new MediaDurationInfo(); + const seconds = 3 * 86400 + 10 * 3600 + 15 * 60 + 9; // 3d 10:15:09 => 82:15:09 + mdi.update(seconds); + expect(mdi.toString()).toBe('82:15:09'); + expect(mdi.ariaLabel()).toBe('82 hours, 15 minutes, 9 seconds'); + expect(mdi.ISO8601()).toBe('P0Y0M0DT82H15M9S'); + }); + + test('Caching: toString and ariaLabel recompute only after update()', () => { + const mdi = new MediaDurationInfo(59); + const firstToString = mdi.toString(); + const firstAria = mdi.ariaLabel(); + expect(firstToString).toBe('0:59'); + expect(firstAria).toBe('59 seconds'); + + // Call again to hit cached path + expect(mdi.toString()).toBe(firstToString); + expect(mdi.ariaLabel()).toBe(firstAria); + + // Update and ensure cache invalidates + mdi.update(60); + expect(mdi.toString()).toBe('1:00'); + expect(mdi.ariaLabel()).toBe('1 minutes'); + }); + + test('Ignores invalid (non-positive integer or zero) updates, retaining previous value', () => { + const mdi = new MediaDurationInfo(10); + expect(mdi.toString()).toBe('0:10'); + + mdi.update(1.23); + expect(mdi.toString()).toBe('0:10'); + + mdi.update(-5); + expect(mdi.toString()).toBe('0:10'); + + mdi.update('x'); + expect(mdi.toString()).toBe('0:10'); + }); + + test('Boundary conditions around a minute and an hour', () => { + const mdi = new MediaDurationInfo(); + + mdi.update(59); + expect(mdi.toString()).toBe('0:59'); + + mdi.update(60); + expect(mdi.toString()).toBe('1:00'); + + mdi.update(3599); + expect(mdi.toString()).toBe('59:59'); + + mdi.update(3600); + expect(mdi.toString()).toBe('1:00:00'); + }); + + // @todo: Revisit this behavior + test('Constructs without initial seconds', () => { + const mdi = new MediaDurationInfo(); + expect(typeof mdi.toString()).toBe('function'); + expect(mdi.ariaLabel()).toBe(''); + expect(mdi.ISO8601()).toBe('P0Y0M0DTundefinedHundefinedMundefinedS'); + }); + }); +}); diff --git a/frontend/tests/utils/classes/UpNextLoaderView.test.ts b/frontend/tests/utils/classes/UpNextLoaderView.test.ts new file mode 100644 index 00000000..e785333f --- /dev/null +++ b/frontend/tests/utils/classes/UpNextLoaderView.test.ts @@ -0,0 +1,102 @@ +import { UpNextLoaderView } from '../../../src/static/js/utils/classes/UpNextLoaderView'; + +// Minimal helpers mocks used by UpNextLoaderView +jest.mock('../../../src/static/js/utils/helpers/', () => ({ + addClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.add(cn)), + removeClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.remove(cn)), + translateString: (s: string) => s, +})); + +const { addClassname, removeClassname } = jest.requireMock('../../../src/static/js/utils/helpers/'); + +const makeNextItem = () => ({ + url: '/next-url', + title: 'Next title', + author_name: 'Jane Doe', + thumbnail_url: 'https://example.com/thumb.jpg', +}); + +describe('utils/classes', () => { + describe('UpNextLoaderView', () => { + test('html() builds structure with expected classes and content', () => { + const v = new UpNextLoaderView(makeNextItem()); + + const root = v.html(); + + expect(root).toBeInstanceOf(HTMLElement); + expect(root.querySelector('.up-next-loader-inner')).not.toBeNull(); + expect(root.querySelector('.up-next-label')!.textContent).toBe('Up Next'); + expect(root.querySelector('.next-media-title')!.textContent).toBe('Next title'); + expect(root.querySelector('.next-media-author')!.textContent).toBe('Jane Doe'); + + // poster background + const poster = root.querySelector('.next-media-poster') as HTMLElement; + expect(poster.style.backgroundImage).toContain('thumb.jpg'); + + // go-next link points to next url + const link = root.querySelector('.go-next a') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe('/next-url'); + }); + + test('setVideoJsPlayerElem marks player with vjs-mediacms-has-up-next-view class', () => { + const v = new UpNextLoaderView(makeNextItem()); + const player = document.createElement('div'); + + v.setVideoJsPlayerElem(player); + + expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-has-up-next-view'); + expect(v.vjsPlayerElem).toBe(player); + }); + + test('startTimer shows view, registers scroll, and navigates after 10s', () => { + const next = makeNextItem(); + const v = new UpNextLoaderView(next); + const player = document.createElement('div'); + + v.setVideoJsPlayerElem(player); + v.startTimer(); + + expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden'); + expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next'); + }); + + test('cancelTimer clears timeout, stops scroll, and marks canceled', () => { + const v = new UpNextLoaderView(makeNextItem()); + const player = document.createElement('div'); + + v.setVideoJsPlayerElem(player); + + v.startTimer(); + v.cancelTimer(); + + expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next'); + }); + + test('Cancel button click hides the view and cancels timer', () => { + const v = new UpNextLoaderView(makeNextItem()); + const player = document.createElement('div'); + v.setVideoJsPlayerElem(player); + + v.startTimer(); + const root = v.html(); + const cancelBtn = root.querySelector('.up-next-cancel button') as HTMLButtonElement; + cancelBtn.click(); + + expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next'); + }); + + test('showTimerView shows or starts timer based on flag', () => { + const v = new UpNextLoaderView(makeNextItem()); + const player = document.createElement('div'); + v.setVideoJsPlayerElem(player); + + // beginTimer=false -> just show view + v.showTimerView(false); + expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden'); + + // beginTimer=true -> starts timer + v.showTimerView(true); + expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next'); + }); + }); +});