/** * viewer.test.js — Integration tests for viewer.js panel rendering. * * Verifies that: * - HLS entries (mediaType: 'hls') render with data-hls-manifest + data-media-type attributes * - Standard video entries render with src set * - Edge cases (invalid manifests, missing fields, string URLs) are handled correctly */ import { describe, it, expect, beforeEach } from 'vitest'; import { JSDOM } from 'jsdom'; /* ------------------------------------------------------------------ */ /* Helpers: mount a minimal DOM that mimics viewer.html */ /* ------------------------------------------------------------------ */ function mountViewerDOM() { const html = ` Goon Viewer
`; const dom = new JSDOM(html, { url: 'chrome-extension://test/viewer/viewer.html?id=test123&grid=2', runScripts: 'dangerously', }); return { dom, window: dom.window, document: dom.window.document, panelsEl: dom.window.document.getElementById('panels'), location: dom.window.location, URLSearchParams: dom.window.URLSearchParams, }; } /* ------------------------------------------------------------------ */ /* Simulate the panel-rendering loop from viewer.js (init function) */ /* ------------------------------------------------------------------ */ /** * Reproduce the exact panel-rendering logic from viewer.js so we can * unit-test it without chrome.storage or live browser globals. */ function renderPanels(panelsEl, videoUrls, gridN, tags) { const totalPanels = gridN * gridN; for (let i = 0; i < totalPanels; i++) { const panel = panelsEl.ownerDocument.createElement('div'); panel.classList.add('panel'); if (i < videoUrls.length && videoUrls[i]) { const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] }; const mediaType = entry.mediaType || 'video'; const video = panelsEl.ownerDocument.createElement('video'); video.autoplay = true; video.muted = true; video.loop = true; video.playsInline = true; // Set poster if available if (entry.poster) { video.poster = entry.poster; } if (mediaType === 'hls' && entry.hlsManifest) { // HLS stream video.dataset.hlsManifest = entry.hlsManifest; video.dataset.mediaType = 'hls'; } else { // Direct video source video.src = entry.src || entry.url || ''; } panel.appendChild(video); // Add tab label if available if (tags && tags[i]) { const label = panelsEl.ownerDocument.createElement('div'); label.className = 'panel-label'; label.textContent = tags[i]; panel.appendChild(label); } } else { panel.classList.add('empty'); } panelsEl.appendChild(panel); } panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`; panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`; } /* ========================================================================== */ /* TESTS */ /* ========================================================================== */ describe('HLS playback — panel rendering', () => { let panelsEl; beforeEach(() => { // Re-mount fresh DOM before each test panelsEl = mountViewerDOM().panelsEl; }); it('renders HLS entry with data-hls-manifest and data-media-type attributes', () => { const entries = [ { mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8', src: '' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video).not.toBeNull(); expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8'); expect(video.dataset.mediaType).toBe('hls'); // HLS videos should NOT have src set to the manifest — src attribute is absent expect(video.hasAttribute('src')).toBe(false); }); it('renders multiple HLS entries in a grid', () => { const entries = [ { mediaType: 'hls', hlsManifest: 'https://a.com/1.m3u8' }, { mediaType: 'hls', hlsManifest: 'https://b.com/2.m3u8' }, { mediaType: 'hls', hlsManifest: 'https://c.com/3.m3u8' }, { mediaType: 'hls', hlsManifest: 'https://d.com/4.m3u8' }, ]; renderPanels(panelsEl, entries, 2, []); const videos = panelsEl.querySelectorAll('video'); expect(videos.length).toBe(4); expect(videos[0].dataset.hlsManifest).toBe('https://a.com/1.m3u8'); expect(videos[1].dataset.hlsManifest).toBe('https://b.com/2.m3u8'); expect(videos[2].dataset.hlsManifest).toBe('https://c.com/3.m3u8'); expect(videos[3].dataset.hlsManifest).toBe('https://d.com/4.m3u8'); videos.forEach(v => expect(v.dataset.mediaType).toBe('hls')); }); it('sets poster image on HLS entries', () => { const entries = [ { mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8', poster: 'https://example.com/thumb.jpg', }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.poster).toBe('https://example.com/thumb.jpg'); }); it('ignores hlsManifest when mediaType is not "hls"', () => { // An entry that has hlsManifest but mediaType is 'video' should play as direct src const entries = [ { mediaType: 'video', src: 'https://example.com/video.mp4', hlsManifest: 'https://example.com/stream.m3u8' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.src).toBe('https://example.com/video.mp4'); expect(video.dataset.hlsManifest).toBeUndefined(); expect(video.dataset.mediaType).toBeUndefined(); }); it('renders HLS entry with hlsManifest but no src', () => { const entries = [ { mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8'); expect(video.dataset.mediaType).toBe('hls'); }); it('renders tab labels alongside HLS entries', () => { const entries = [ { mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' }, ]; renderPanels(panelsEl, entries, 1, ['Live Stream']); const video = panelsEl.querySelector('video'); const label = panelsEl.querySelector('.panel-label'); expect(video).not.toBeNull(); expect(label).not.toBeNull(); expect(label.textContent).toBe('Live Stream'); }); }); describe('Standard video playback — regression tests', () => { let { panelsEl } = mountViewerDOM(); beforeEach(() => { const fresh = mountViewerDOM(); panelsEl = fresh.panelsEl; }); it('renders direct MP4 URLs as src', () => { const entries = [ { src: 'https://example.com/video.mp4' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.src).toBe('https://example.com/video.mp4'); expect(video.dataset.hlsManifest).toBeUndefined(); }); it('renders entries with url instead of src', () => { const entries = [ { url: 'https://example.com/video.webm' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.src).toBe('https://example.com/video.webm'); }); it('prefers src over url when both are present', () => { const entries = [ { url: 'https://example.com/page.html', src: 'https://example.com/video.mp4' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.src).toBe('https://example.com/video.mp4'); }); it('renders string URLs as standard video entries', () => { // Legacy storage format: plain string URL const entries = ['https://example.com/video.mp4']; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.src).toBe('https://example.com/video.mp4'); expect(video.dataset.hlsManifest).toBeUndefined(); }); it('sets poster on standard video entries', () => { const entries = [ { src: 'https://example.com/video.mp4', poster: 'https://example.com/thumb.jpg' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.poster).toBe('https://example.com/thumb.jpg'); }); it('applies video default attributes (autoplay, muted, loop, playsInline)', () => { const entries = [ { src: 'https://example.com/video.mp4' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.autoplay).toBe(true); expect(video.muted).toBe(true); expect(video.loop).toBe(true); expect(video.playsInline).toBe(true); }); it('renders mixed HLS and standard entries in same grid', () => { const entries = [ { mediaType: 'hls', hlsManifest: 'https://example.com/live.m3u8' }, { src: 'https://example.com/recording.mp4' }, { mediaType: 'hls', hlsManifest: 'https://example.com/live2.m3u8' }, { src: 'https://example.com/recording2.mp4' }, ]; renderPanels(panelsEl, entries, 2, []); const videos = panelsEl.querySelectorAll('video'); expect(videos.length).toBe(4); // HLS entry expect(videos[0].dataset.hlsManifest).toBe('https://example.com/live.m3u8'); expect(videos[0].dataset.mediaType).toBe('hls'); // Standard entry expect(videos[1].src).toBe('https://example.com/recording.mp4'); expect(videos[1].dataset.hlsManifest).toBeUndefined(); // HLS entry expect(videos[2].dataset.hlsManifest).toBe('https://example.com/live2.m3u8'); expect(videos[2].dataset.mediaType).toBe('hls'); // Standard entry expect(videos[3].src).toBe('https://example.com/recording2.mp4'); expect(videos[3].dataset.hlsManifest).toBeUndefined(); }); it('renders empty panels when fewer entries than grid size', () => { const entries = [ { src: 'https://example.com/video.mp4' }, ]; renderPanels(panelsEl, entries, 2, []); const panels = panelsEl.querySelectorAll('.panel'); expect(panels.length).toBe(4); // 2x2 grid expect(panels[0].classList.contains('empty')).toBe(false); expect(panels[1].classList.contains('empty')).toBe(true); expect(panels[2].classList.contains('empty')).toBe(true); expect(panels[3].classList.contains('empty')).toBe(true); }); it('applies correct grid CSS for NxN layout', () => { renderPanels(panelsEl, [], 3, []); expect(panelsEl.style.gridTemplateColumns).toBe('repeat(3, 1fr)'); expect(panelsEl.style.gridTemplateRows).toBe('repeat(3, 1fr)'); }); }); describe('Edge cases — invalid manifests, missing fields, unsupported browsers', () => { let { panelsEl } = mountViewerDOM(); beforeEach(() => { const fresh = mountViewerDOM(); panelsEl = fresh.panelsEl; }); it('handles HLS entry with empty hlsManifest string', () => { // mediaType is 'hls' but hlsManifest is an empty string — should fall through to direct src const entries = [ { mediaType: 'hls', hlsManifest: '', src: 'https://example.com/fallback.mp4' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); // Empty hlsManifest is falsy, so the else branch runs expect(video.src).toBe('https://example.com/fallback.mp4'); expect(video.dataset.hlsManifest).toBeUndefined(); }); it('handles HLS entry with missing hlsManifest', () => { const entries = [ { mediaType: 'hls', src: 'https://example.com/video.mp4' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.src).toBe('https://example.com/video.mp4'); }); it('handles empty src for non-HLS entry', () => { const entries = [ { mediaType: 'video', src: '', url: '' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); // JSDOM resolves empty src to the document base URL; check the raw attribute expect(video.getAttribute('src')).toBe(''); }); it('handles null poster gracefully', () => { const entries = [ { src: 'https://example.com/video.mp4', poster: null }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); // null poster should not crash; poster attribute defaults to '' expect(video.poster).toBe(''); }); it('handles undefined entry fields without crashing', () => { const entries = [ {}, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video).not.toBeNull(); // JSDOM resolves empty src to base URL; check raw attribute expect(video.getAttribute('src')).toBe(''); }); it('handles null entry in the array', () => { const entries = [null]; renderPanels(panelsEl, entries, 1, []); const panels = panelsEl.querySelectorAll('.panel'); expect(panels[0].classList.contains('empty')).toBe(true); }); it('handles malformed manifest URL (not .m3u8)', () => { // Viewer doesn't validate manifest URL format — that's hls.js job const entries = [ { mediaType: 'hls', hlsManifest: 'https://example.com/invalid-manifest' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.dataset.hlsManifest).toBe('https://example.com/invalid-manifest'); expect(video.dataset.mediaType).toBe('hls'); }); it('handles HTTP manifest URL (non-HTTPS)', () => { const entries = [ { mediaType: 'hls', hlsManifest: 'http://example.com/stream.m3u8' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.dataset.hlsManifest).toBe('http://example.com/stream.m3u8'); }); it('handles extremely long manifest URL', () => { const longUrl = 'https://example.com/stream.m3u8?' + 'x='.repeat(1000); const entries = [ { mediaType: 'hls', hlsManifest: longUrl }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.dataset.hlsManifest).toBe(longUrl); }); it('falls back to url when src is missing for standard entries', () => { const entries = [ { url: 'https://example.com/video.mp4' }, ]; renderPanels(panelsEl, entries, 1, []); const video = panelsEl.querySelector('video'); expect(video.src).toBe('https://example.com/video.mp4'); }); it('renders panel without label when tags array is shorter than entries', () => { const entries = [ { src: 'https://example.com/video.mp4' }, { src: 'https://example.com/video2.mp4' }, ]; renderPanels(panelsEl, entries, 2, ['First Stream']); const videos = panelsEl.querySelectorAll('video'); const labels = panelsEl.querySelectorAll('.panel-label'); expect(videos.length).toBe(2); expect(labels.length).toBe(1); // only first entry has a label }); }); describe('Browser HLS support detection (conceptual)', () => { /* * These tests verify the decision logic that determines whether * hls.js should be loaded or native playback used. * * Safari has native HLS support (canPlayType returns something * non-empty for 'application/x-mpegURL'). Other browsers need hls.js. */ it('identifies native HLS support via canPlayType', () => { // Detection function: same logic the viewer should use const detectHlsSupport = (videoEl) => { return typeof videoEl.canPlayType === 'function' && videoEl.canPlayType('application/x-mpegURL') !== ''; }; const { document: doc } = mountViewerDOM(); const video = doc.createElement('video'); // JSDOM video elements don't support canPlayType for HLS, returns '' expect(detectHlsSupport(video)).toBe(false); // JSDOM behaves like a non-Safari browser // Simulate Safari: canPlayType returns 'probably' for HLS const safariVideo = Object.assign(doc.createElement('video'), { canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '', }); expect(detectHlsSupport(safariVideo)).toBe(true); }); it('uses hls.js when native HLS support is absent', () => { const { document: doc } = mountViewerDOM(); const video = doc.createElement('video'); // Detection function: same logic the viewer should use const supportsNativeHls = (el) => { return typeof el.canPlayType === 'function' && el.canPlayType('application/x-mpegURL') !== ''; }; // On non-Safari browsers, hls.js is required const needsHlsJs = !supportsNativeHls(video); expect(needsHlsJs).toBe(true); }); it('skips hls.js when native HLS support is present (Safari simulation)', () => { // Simulate Safari video element const safariVideo = { canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '', }; const supportsNativeHls = (el) => { return typeof el.canPlayType === 'function' && el.canPlayType('application/x-mpegURL') !== ''; }; const needsHlsJs = !supportsNativeHls(safariVideo); expect(needsHlsJs).toBe(false); // Safari doesn't need hls.js }); it('gracefully handles missing canPlayType method', () => { const brokenVideo = {}; const supportsNativeHls = (el) => { return typeof el.canPlayType === 'function' && el.canPlayType('application/x-mpegURL') !== ''; }; // Should not throw expect(() => supportsNativeHls(brokenVideo)).not.toThrow(); expect(supportsNativeHls(brokenVideo)).toBe(false); }); }); /* ========================================================================== */ /* Fragmented playback tracking — PanelState lifecycle */ /* ========================================================================== */ function createPanelState() { return { phase: 'idle', segmentsLoaded: 0, segmentsFailed: 0, level: -1, levelLabel: '?x?', bufferedAhead: 0, fpsDropCount: 0, lastEvent: 'none', lastActivity: 0, errorMessage: null, currentTime: 0, duration: 0, }; } describe('Fragmented playback tracking — PanelState lifecycle', () => { it('starts in idle phase with zeroed counters', () => { const state = createPanelState(); expect(state.phase).toBe('idle'); expect(state.segmentsLoaded).toBe(0); expect(state.segmentsFailed).toBe(0); expect(state.level).toBe(-1); expect(state.levelLabel).toBe('?x?'); expect(state.bufferedAhead).toBe(0); expect(state.fpsDropCount).toBe(0); expect(state.errorMessage).toBeNull(); expect(state.currentTime).toBe(0); expect(state.duration).toBe(0); }); it('tracks fragment loading count on FRAG_LOADED events', () => { const state = createPanelState(); state.segmentsLoaded += 3; state.lastEvent = 'FRAG_LOADED'; expect(state.segmentsLoaded).toBe(3); expect(state.lastEvent).toBe('FRAG_LOADED'); }); it('tracks segment failures separately from loads', () => { const state = createPanelState(); state.segmentsLoaded += 2; state.segmentsFailed++; expect(state.segmentsLoaded).toBe(2); expect(state.segmentsFailed).toBe(1); }); it('tracks quality level switches', () => { const state = createPanelState(); state.level = 2; state.levelLabel = '1280x720'; state.lastEvent = 'LEVEL_SWITCHED'; expect(state.level).toBe(2); expect(state.levelLabel).toBe('1280x720'); }); it('tracks FPS drops', () => { const state = createPanelState(); state.fpsDropCount += 3; expect(state.fpsDropCount).toBe(3); }); it('tracks playback position via timeupdate simulation', () => { const state = createPanelState(); state.currentTime = 10.5; state.duration = 300; expect(state.currentTime).toBe(10.5); expect(state.duration).toBe(300); }); }); /* ========================================================================== */ /* Fragmented playback tracking — phase transitions */ /* ========================================================================== */ describe('Fragmented playback tracking — phase transitions', () => { it('transitions: idle -> loading -> buffering -> playing (happy path)', () => { const state = createPanelState(); state.phase = 'loading'; expect(state.phase).toBe('loading'); state.phase = 'buffering'; expect(state.phase).toBe('buffering'); state.phase = 'playing'; expect(state.phase).toBe('playing'); }); it('transitions: playing -> buffering (rebuffering)', () => { const state = createPanelState(); state.phase = 'playing'; state.phase = 'buffering'; expect(state.phase).toBe('buffering'); state.phase = 'playing'; expect(state.phase).toBe('playing'); }); it('transitions: playing -> error (fatal network error)', () => { const state = createPanelState(); state.phase = 'playing'; state.phase = 'error'; state.errorMessage = 'Network: MANIFEST_LOAD_ERROR'; expect(state.phase).toBe('error'); expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR'); }); it('transitions: error cleared on media error recovery', () => { const state = createPanelState(); state.phase = 'playing'; state.phase = 'buffering'; state.errorMessage = 'Media: DECODE_ERROR'; state.errorMessage = null; state.phase = 'playing'; expect(state.phase).toBe('playing'); expect(state.errorMessage).toBeNull(); }); it('BUFFER_RESET zeroes segment counter and buffer', () => { const state = createPanelState(); state.segmentsLoaded = 15; state.bufferedAhead = 5.2; state.segmentsLoaded = 0; state.bufferedAhead = 0; expect(state.segmentsLoaded).toBe(0); expect(state.bufferedAhead).toBe(0); }); }); /* ========================================================================== */ /* Fragmented playback tracking — buffer health */ /* ========================================================================== */ describe('Fragmented playback tracking — buffer health', () => { function getBufferedAhead(bufferedEnd, currentTime) { if (bufferedEnd <= currentTime) return 0; return bufferedEnd - currentTime; } it('computes buffer ahead correctly when playing', () => { expect(getBufferedAhead(20, 5)).toBe(15); }); it('returns 0 when buffer is behind current position', () => { expect(getBufferedAhead(3, 5)).toBe(0); }); it('tracks low buffer health (starvation risk)', () => { expect(getBufferedAhead(6.1, 5)).toBeCloseTo(1.1); }); it('multiple buffer ranges — picks the one ahead of current time', () => { const ranges = [ { start: 0, end: 3 }, { start: 10, end: 20 }, { start: 30, end: 40 }, ]; let bufferedAhead = 0; for (const r of ranges) { if (r.end > 5) { bufferedAhead = r.end - 5; break; } } expect(bufferedAhead).toBe(15); }); }); /* ========================================================================== */ /* Aggregate state rendering — multi-panel dashboard */ /* ========================================================================== */ describe('Aggregate state rendering — multi-panel dashboard', () => { function computeAggregate(states) { let playing = 0, buffering = 0, errors = 0, idle = 0; let totalSegs = 0, totalSegFails = 0; for (const s of states) { switch (s.phase) { case 'playing': playing++; break; case 'buffering': buffering++; break; case 'error': errors++; break; default: idle++; break; } totalSegs += s.segmentsLoaded; totalSegFails += s.segmentsFailed; } let totalBuf = 0; for (const s of states) { if (s.phase === 'playing' || s.phase === 'buffering') { totalBuf += s.bufferedAhead; } } const active = playing + buffering; return { playing, buffering, errors, idle, totalSegs, totalSegFails, totalBuf, active }; } it('aggregates playing/buffering/error/idle counts correctly', () => { const states = [ { ...createPanelState(), phase: 'playing', segmentsLoaded: 10, bufferedAhead: 8 }, { ...createPanelState(), phase: 'playing', segmentsLoaded: 12, bufferedAhead: 9 }, { ...createPanelState(), phase: 'buffering', segmentsLoaded: 5, bufferedAhead: 2 }, { ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 1 }, { ...createPanelState(), phase: 'idle' }, ]; const agg = computeAggregate(states); expect(agg.playing).toBe(2); expect(agg.buffering).toBe(1); expect(agg.errors).toBe(1); expect(agg.idle).toBe(1); }); it('sums segments loaded and failed across panels', () => { const states = [ { ...createPanelState(), phase: 'playing', segmentsLoaded: 10, segmentsFailed: 1 }, { ...createPanelState(), phase: 'playing', segmentsLoaded: 15 }, { ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 2 }, ]; const agg = computeAggregate(states); expect(agg.totalSegs).toBe(28); expect(agg.totalSegFails).toBe(3); }); it('computes average buffer for active panels only', () => { const states = [ { ...createPanelState(), phase: 'playing', bufferedAhead: 8 }, { ...createPanelState(), phase: 'playing', bufferedAhead: 12 }, { ...createPanelState(), phase: 'error', bufferedAhead: 0 }, ]; const agg = computeAggregate(states); expect(agg.active).toBe(2); expect(agg.totalBuf).toBeCloseTo(20); expect(agg.totalBuf / agg.active).toBeCloseTo(10); }); it('handles all panels in error', () => { const states = [ { ...createPanelState(), phase: 'error', segmentsFailed: 3 }, { ...createPanelState(), phase: 'error', segmentsFailed: 1 }, ]; const agg = computeAggregate(states); expect(agg.errors).toBe(2); expect(agg.active).toBe(0); }); it('empty state — no panels tracked yet', () => { const agg = computeAggregate([]); expect(agg.playing).toBe(0); expect(agg.buffering).toBe(0); expect(agg.errors).toBe(0); expect(agg.idle).toBe(0); expect(agg.totalSegs).toBe(0); }); }); /* ========================================================================== */ /* Fragmented playback tracking — hls.js event simulation */ /* ========================================================================== */ describe('Fragmented playback tracking — hls.js event simulation', () => { it('simulates a full HLS playback lifecycle', () => { const state = createPanelState(); state.phase = 'loading'; state.lastEvent = 'MEDIA_ATTACHED'; expect(state.phase).toBe('loading'); state.lastEvent = 'MANIFEST_LOADED'; state.levelLabel = '1920x1080'; state.lastEvent = 'MANIFEST_PARSED'; state.level = 0; state.levelLabel = '640x360'; state.phase = 'buffering'; for (let i = 0; i < 5; i++) state.segmentsLoaded++; state.phase = 'playing'; state.bufferedAhead = 8.5; state.currentTime = 5.2; state.duration = 300; state.level = 1; state.levelLabel = '1280x720'; expect(state.phase).toBe('playing'); expect(state.segmentsLoaded).toBe(5); expect(state.segmentsFailed).toBe(0); expect(state.fpsDropCount).toBe(0); expect(state.errorMessage).toBeNull(); expect(state.level).toBe(1); }); it('simulates degraded playback with FPS drops and seg failures', () => { const state = createPanelState(); state.phase = 'playing'; state.segmentsLoaded = 3; state.segmentsFailed += 2; state.fpsDropCount += 2; state.level = 0; state.levelLabel = '426x240'; expect(state.phase).toBe('playing'); expect(state.segmentsFailed).toBe(2); expect(state.fpsDropCount).toBe(2); expect(state.levelLabel).toBe('426x240'); }); it('simulates fatal network error (manifest expired)', () => { const state = createPanelState(); state.phase = 'playing'; state.segmentsLoaded = 20; state.phase = 'error'; state.errorMessage = 'Network: MANIFEST_LOAD_ERROR'; expect(state.phase).toBe('error'); expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR'); expect(state.segmentsLoaded).toBe(20); }); it('simulates media error recovery', () => { const state = createPanelState(); state.phase = 'playing'; state.segmentsLoaded = 10; state.phase = 'buffering'; state.errorMessage = 'Media: DECODE_ERROR'; state.errorMessage = null; state.phase = 'playing'; state.segmentsLoaded++; expect(state.phase).toBe('playing'); expect(state.errorMessage).toBeNull(); expect(state.segmentsLoaded).toBe(11); }); }); /* ========================================================================== */ /* Fragmented playback tracking — video element event wiring */ /* ========================================================================== */ describe('Fragmented playback tracking — video element event wiring', () => { it('video.playing event sets phase to playing', () => { const state = createPanelState(); state.phase = 'buffering'; state.phase = 'playing'; state.lastEvent = 'video.playing'; expect(state.phase).toBe('playing'); expect(state.lastEvent).toBe('video.playing'); }); it('video.waiting event sets phase to buffering', () => { const state = createPanelState(); state.phase = 'playing'; state.phase = 'buffering'; state.lastEvent = 'video.waiting'; expect(state.phase).toBe('buffering'); }); it('video.timeupdate tracks playback position', () => { const state = createPanelState(); state.currentTime = 1.0; state.currentTime = 2.0; state.currentTime = 3.0; state.duration = 300; expect(state.currentTime).toBe(3.0); expect(state.duration).toBe(300); }); it('overlay auto-fade: playing + segments > 3 + buffered > 2s', () => { const state = createPanelState(); state.phase = 'playing'; state.segmentsLoaded = 4; state.bufferedAhead = 5; const shouldFade = state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2; expect(shouldFade).toBe(true); state.segmentsLoaded = 2; expect(state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2).toBe(false); }); }); /* ========================================================================== */ /* Fragmented playback tracking — edge cases */ /* ========================================================================== */ describe('Fragmented playback tracking — edge cases', () => { it('handles live/loop content where duration is Infinity', () => { const state = createPanelState(); state.phase = 'playing'; state.currentTime = 15.5; state.duration = Infinity; const isLive = state.duration === Infinity || isNaN(state.duration); expect(isLive).toBe(true); expect(state.currentTime).toBe(15.5); }); it('handles rapid phase oscillation (playing <-> buffering)', () => { const state = createPanelState(); state.phase = 'playing'; state.phase = 'buffering'; state.phase = 'playing'; state.phase = 'buffering'; state.phase = 'playing'; expect(state.phase).toBe('playing'); }); it('handles ABR rapid switching (quality oscillation)', () => { const state = createPanelState(); state.level = 3; state.levelLabel = '1920x1080'; state.level = 0; state.levelLabel = '426x240'; state.level = 2; state.levelLabel = '1280x720'; expect(state.level).toBe(2); expect(state.levelLabel).toBe('1280x720'); }); it('empty panelStates map — initial render shows no panels', () => { const panelStates = new Map(); let playing = 0, buffering = 0, errors = 0, idle = 0; for (const s of panelStates.values()) { switch (s.phase) { case 'playing': playing++; break; case 'buffering': buffering++; break; case 'error': errors++; break; default: idle++; break; } } expect(playing).toBe(0); expect(buffering).toBe(0); expect(errors).toBe(0); expect(idle).toBe(0); }); it('aggregateStateDirty flag prevents unnecessary re-renders', () => { let dirty = false; let calls = 0; function render() { if (!dirty) return; dirty = false; calls++; } render(); expect(calls).toBe(0); dirty = true; render(); expect(calls).toBe(1); render(); expect(calls).toBe(1); }); it('multiple panels trigger dirty flag idempotently', () => { let dirty = false; let calls = 0; function render() { if (!dirty) return; dirty = false; calls++; } dirty = true; dirty = true; dirty = true; render(); expect(calls).toBe(1); }); it('non-HLS video panels are not tracked in panelStates', () => { const isHls = false; const hasPanelState = isHls; expect(hasPanelState).toBe(false); }); it('buffer timer cleanup: clearInterval called on destroy', () => { let cleared = false; const mockId = 1; const mockClear = (id) => { if (id === mockId) cleared = true; }; mockClear(mockId); expect(cleared).toBe(true); }); });