import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { setupVideoPlayback, setupMediaToggle } from './controls'; // Import setupMediaToggle
// Helper function to create a log entry element
const createLogEntry = (doc: Document, time: number): HTMLElement => {
const entry = doc.createElement('div');
entry.classList.add('haibun-log-entry');
entry.dataset.time = `${time}`;
// Add some content to make it clickable
const summary = doc.createElement('summary');
summary.textContent = `Log at ${time}`;
entry.appendChild(summary);
return entry;
};
// Helper function to dispatch events
const dispatchEvent = (element: Element, eventName: string) => {
const event = new Event(eventName, { bubbles: true });
element.dispatchEvent(event);
};
describe('Monitor Video Playback Controls', () => {
let dom: JSDOM;
let document: Document;
let window: Window & typeof globalThis;
let logDisplayArea: HTMLElement;
let videoContainer: HTMLElement;
let videoElement: HTMLVideoElement;
let videoStartElement: HTMLElement;
beforeEach(() => {
const monitorStartTime = Date.now(); // Use a realistic start time
const videoStartOffset = 1000; // Offset of video start relative to monitor start (ms)
// Setup JSDOM
dom = new JSDOM(`
`, { url: "http://localhost", runScripts: "dangerously", pretendToBeVisual: true });
window = dom.window as unknown as Window & typeof globalThis;
document = window.document;
// Assign to global context for the controls script
global.window = window;
global.document = document;
global.Event = window.Event; // Make Event constructor available
global.HTMLVideoElement = window.HTMLVideoElement;
global.HTMLElement = window.HTMLElement;
// Mock MutationObserver for JSDOM environment
global.MutationObserver = vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
takeRecords: vi.fn(() => []),
}));
// Mock video methods/properties needed for tests
window.HTMLVideoElement.prototype.play = vi.fn();
window.HTMLVideoElement.prototype.pause = vi.fn();
Object.defineProperty(window.HTMLVideoElement.prototype, 'currentTime', {
writable: true,
value: 0,
});
// Mock requestAnimationFrame for scrollIntoView({ behavior: 'smooth' })
window.requestAnimationFrame = vi.fn((cb) => { cb(Date.now()); return 0; });
window.HTMLElement.prototype.scrollIntoView = vi.fn();
// Get DOM elements
logDisplayArea = document.getElementById('haibun-log-display-area')!;
videoContainer = document.getElementById('haibun-focus')!;
videoElement = videoContainer.querySelector('video')!;
videoStartElement = document.getElementById('haibun-video-start')!;
// Add some log entries with absolute timestamps
// logAbsoluteTime = monitorStartTime + videoStartOffset + relativeVideoTimeMs
logDisplayArea.appendChild(createLogEntry(document, monitorStartTime + videoStartOffset + 500)); // 0.5s relative to video start
logDisplayArea.appendChild(createLogEntry(document, monitorStartTime + videoStartOffset + 1500)); // 1.5s relative to video start
logDisplayArea.appendChild(createLogEntry(document, monitorStartTime + videoStartOffset + 2500)); // 2.5s relative to video start
// Setup the controls after the DOM is ready
setupVideoPlayback(); // Directly test the relevant part
});
afterEach(() => {
// Cleanup JSDOM window/document
vi.restoreAllMocks();
window.close();
});
it('should seek video when a log entry is clicked', () => {
const logEntries = logDisplayArea.querySelectorAll('.haibun-log-entry');
const secondLogEntry = logEntries[1]; // Corresponds to 1.5s relative video time
// Simulate click
dispatchEvent(secondLogEntry.querySelector('summary')!, 'click'); // Click the summary inside
// Expect currentTime to be set (relative time in seconds)
// Expect currentTime to be set to the time relative to video start
expect(videoElement.currentTime).toBe(1.5);
});
it('should update classes correctly on seeked event', () => {
const logEntries = logDisplayArea.querySelectorAll('.haibun-log-entry');
const firstLogEntry = logEntries[0]; // 0.5s relative
const secondLogEntry = logEntries[1]; // 1.5s relative
const thirdLogEntry = logEntries[2]; // 2.5s relative
// Simulate seeking to 1.6s (relative)
videoElement.currentTime = 1.6;
dispatchEvent(videoElement, 'seeked');
expect(firstLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(firstLogEntry.classList.contains('haibun-stepper-current')).toBe(false); // 0.5s is before 1.6s
expect(secondLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(secondLogEntry.classList.contains('haibun-stepper-current')).toBe(true); // 1.5s is the latest <= 1.6s
expect(thirdLogEntry.classList.contains('haibun-stepper-notplayed')).toBe(true);
expect(thirdLogEntry.classList.contains('haibun-stepper-current')).toBe(false);
});
it('should update classes correctly during playback via interval', async () => {
vi.useFakeTimers();
const logEntries = logDisplayArea.querySelectorAll('.haibun-log-entry');
const firstLogEntry = logEntries[0]; // 0.5s relative
const secondLogEntry = logEntries[1]; // 1.5s relative
const thirdLogEntry = logEntries[2]; // 2.5s relative
// Simulate play starting
dispatchEvent(videoElement, 'play');
// Advance time to 0.6s (relative)
videoElement.currentTime = 0.6;
vi.advanceTimersByTime(50); // Trigger interval
await vi.runOnlyPendingTimersAsync(); // Ensure async operations complete if any
expect(firstLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(firstLogEntry.classList.contains('haibun-stepper-current')).toBe(true);
expect(secondLogEntry.classList.contains('haibun-stepper-notplayed')).toBe(true);
expect(thirdLogEntry.classList.contains('haibun-stepper-notplayed')).toBe(true);
// Advance time to 1.8s (relative)
videoElement.currentTime = 1.8;
vi.advanceTimersByTime(50); // Trigger interval
await vi.runOnlyPendingTimersAsync();
expect(firstLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(firstLogEntry.classList.contains('haibun-stepper-current')).toBe(false);
expect(secondLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(secondLogEntry.classList.contains('haibun-stepper-current')).toBe(true);
expect(thirdLogEntry.classList.contains('haibun-stepper-notplayed')).toBe(true);
// Advance time to 3.0s (relative)
videoElement.currentTime = 3.0;
vi.advanceTimersByTime(50); // Trigger interval
await vi.runOnlyPendingTimersAsync();
expect(firstLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(secondLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(thirdLogEntry.classList.contains('haibun-stepper-played')).toBe(true);
expect(thirdLogEntry.classList.contains('haibun-stepper-current')).toBe(true);
// Simulate pause
dispatchEvent(videoElement, 'pause');
// Check interval is cleared (difficult to assert directly, but ensures no more updates)
vi.useRealTimers();
});
it('should handle missing video start element gracefully', () => {
videoStartElement.remove();
const logEntries = logDisplayArea.querySelectorAll('.haibun-log-entry');
const secondLogEntry = logEntries[1];
// Click should not throw error and not change currentTime
expect(() => {
dispatchEvent(secondLogEntry.querySelector('summary')!, 'click');
}).not.toThrow();
expect(videoElement.currentTime).toBe(0); // Should remain unchanged
// Seeked should not throw error and not change classes
videoElement.currentTime = 1.6;
expect(() => {
dispatchEvent(videoElement, 'seeked');
}).not.toThrow();
expect(secondLogEntry.classList.contains('haibun-stepper-current')).toBe(false);
});
it('should handle invalid start time gracefully', () => {
videoStartElement.dataset.start = "invalid"; // Set invalid start time
const logEntries = logDisplayArea.querySelectorAll('.haibun-log-entry');
const secondLogEntry = logEntries[1];
// Click should not throw error and not change currentTime
expect(() => {
dispatchEvent(secondLogEntry.querySelector('summary')!, 'click');
}).not.toThrow();
expect(videoElement.currentTime).toBe(0);
// Seeked should not throw error and not change classes
videoElement.currentTime = 1.6;
expect(() => {
dispatchEvent(videoElement, 'seeked');
}).not.toThrow();
expect(secondLogEntry.classList.contains('haibun-stepper-current')).toBe(false);
});
it('should handle log entries with missing or invalid data-time gracefully', () => {
const logEntries = logDisplayArea.querySelectorAll('.haibun-log-entry');
const secondLogEntry = logEntries[1];
delete secondLogEntry.dataset.time;
const thirdLogEntry = logEntries[2];
thirdLogEntry.dataset.time = "invalid"; // Set invalid time
// Simulate seeking to 1.6s (relative)
videoElement.currentTime = 1.6;
expect(() => {
dispatchEvent(videoElement, 'seeked');
}).not.toThrow(); // Should not throw
// First entry should still be marked played
expect(logEntries[0].classList.contains('haibun-stepper-played')).toBe(true);
expect(logEntries[0].classList.contains('haibun-stepper-current')).toBe(true); // Becomes current as others are invalid
// Second and third should not have classes applied
expect(secondLogEntry.classList.length).toBe(1); // Only 'haibun-log-entry'
expect(thirdLogEntry.classList.length).toBe(1); // Only 'haibun-log-entry'
});
});