Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { ref } from 'vue';

jest.mock('video.js', () => require('../../test/videojsMock').videojsModuleMock());

/* eslint-disable import-x/first */
import useCaptions from '../useCaptions';
import Settings from '../../utils/settings';
import { createTrack, createFakePlayer } from '../../test/videojsMock';
/* eslint-enable import-x/first */

function createCaptions() {
return useCaptions(ref(createFakePlayer()));
}

describe('useCaptions', () => {
beforeEach(() => {
// Settings persists via Lockr → localStorage; isolate each test.
window.localStorage.clear();
});

it('loads default caption settings (subtitles on, transcript off)', () => {
const captions = createCaptions();
expect(captions.subtitles.value).toBe(true);
expect(captions.transcript.value).toBe(false);
});

it('enables the track matching the active language and exposes its cues', () => {
const captions = createCaptions();
captions.setLanguage('en');

const enCues = [{}, {}];
const en = createTrack({ language: 'en', cues: enCues, activeCues: [enCues[0]] });
const es = createTrack({ language: 'es', cues: [{}] });
captions.setTrackList([en, es]);

expect(en.mode).toBe('showing');
expect(es.mode).toBe('disabled');
expect(captions.cues.value).toHaveLength(2);
expect(captions.activeCueIds.value).toEqual(['en-cue-0']);
});

it('persists caption preferences when toggled', () => {
const captions = createCaptions();
captions.toggleSubtitles();

expect(captions.subtitles.value).toBe(false);
expect(new Settings().captionSubtitles).toBe(false);
});

it('toggleTranscript flips transcript state', () => {
const captions = createCaptions();
captions.toggleTranscript();
expect(captions.transcript.value).toBe(true);
});

it('setLanguage switches the active caption track', () => {
const captions = createCaptions();
const en = createTrack({ language: 'en', cues: [{}, {}] });
const es = createTrack({ language: 'es', cues: [{}] });
captions.setLanguage('en');
captions.setTrackList([en, es]);

captions.setLanguage('es');

expect(captions.language.value).toBe('es');
expect(es.mode).toBe('showing');
expect(en.mode).toBe('disabled');
expect(captions.cues.value).toHaveLength(1);
});

it('picks up cues that load after a language switch', () => {
const captions = createCaptions();
const en = createTrack({ language: 'en', cues: [{}, {}] });
// Tracks are fetched lazily, so a track only enabled later starts empty.
const es = createTrack({ language: 'es' });
captions.setLanguage('en');
captions.setTrackList([en, es]);

captions.setLanguage('es');
expect(captions.cues.value).toEqual([]);

// The VTT finishes parsing after the switch: addCue is the only signal.
es.cues = [{}];
es.addCue({});

expect(captions.cues.value).toHaveLength(1);
});

it('initCaptionState disables captions when no track matches the language', () => {
const captions = createCaptions();
captions.setLanguage('fr'); // no track for fr
captions.setTrackList([createTrack({ language: 'en', cues: [{}] })]);

captions.initCaptionState();

expect(captions.subtitles.value).toBe(false);
expect(captions.transcript.value).toBe(false);
});

it('isDefaultTrack compares by short language code', () => {
const captions = createCaptions();
captions.setLanguage('en');
expect(captions.isDefaultTrack('en')).toBe(true);
expect(captions.isDefaultTrack('es')).toBe(false);
});

it('resetState clears cue state', () => {
const captions = createCaptions();
captions.setLanguage('en');
captions.setTrackList([createTrack({ language: 'en', cues: [{}, {}] })]);
expect(captions.cues.value.length).toBeGreaterThan(0);

captions.resetState();

expect(captions.cues.value).toEqual([]);
expect(captions.activeCueIds.value).toEqual([]);
});

it('resetState detaches cuechange listeners so later track events are ignored', () => {
const captions = createCaptions();
captions.setLanguage('en');
const cues = [{}, {}];
const en = createTrack({ language: 'en', cues, activeCues: [cues[0]] });
captions.setTrackList([en]);
expect(captions.activeCueIds.value).toEqual(['en-cue-0']);

captions.resetState();
expect(captions.activeCueIds.value).toEqual([]);

// A cuechange fired after teardown must not repopulate active cues.
en.activeCues = [cues[1]];
en.trigger('cuechange');
expect(captions.activeCueIds.value).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { ref, computed } from 'vue';
import useMediaProgress from '../useMediaProgress';
import { createFakePlayer } from '../../test/videojsMock';

function createMockPlayer({ duration = 100, ...state } = {}) {
return ref(createFakePlayer({ duration, ...state }));
}

function setup(overrides = {}) {
const emit = jest.fn();
const player = overrides.player || createMockPlayer(overrides.playerState);
const extraFields = overrides.extraFields || ref(null);
const savedLocation =
overrides.savedLocation ||
computed(() => {
if (extraFields.value && extraFields.value.contentState) {
return extraFields.value.contentState.savedLocation;
}
return 0;
});

const result = useMediaProgress({
player,
emit,
forceDurationBasedProgress: overrides.forceDurationBasedProgress || ref(false),
durationBasedProgress: overrides.durationBasedProgress || ref(0),
extraFields,
savedLocation,
});

return { emit, player, ...result };
}

describe('useMediaProgress', () => {
describe('recordProgress', () => {
it('reports the absolute durationBasedProgress and ignores elapsed time when forced', () => {
const timeSpent = 30;
const contentDuration = 300;
const player = createMockPlayer({ duration: 100, currentTime: 0 });
const { emit, updateTime } = setup({
player,
forceDurationBasedProgress: ref(true),
durationBasedProgress: ref(timeSpent / contentDuration),
});

player.value.currentTime(50);
updateTime();

expect(emit).toHaveBeenCalledWith('updateProgress', timeSpent / contentDuration);
expect(emit).not.toHaveBeenCalledWith('addProgress', expect.anything());
});

it('emits addProgress as the fraction of duration newly elapsed since the last record', () => {
const player = createMockPlayer({ duration: 100, currentTime: 0 });
const { emit, updateTime } = setup({ player });

player.value.currentTime(30);
updateTime();
player.value.currentTime(50);
updateTime();

expect(emit.mock.calls).toEqual([
['addProgress', 0.3],
['addProgress', 0.2],
]);
});
});

describe('updateTime', () => {
it('updates dummyTime from player currentTime', () => {
const player = createMockPlayer({ currentTime: 3 });
const { emit, updateTime } = setup({ player });

updateTime();

// 3 seconds elapsed, but less than 5s threshold — no progress emitted
expect(emit).not.toHaveBeenCalled();
});

it('records progress once playback crosses the 5 second threshold', () => {
const player = createMockPlayer({ duration: 100, currentTime: 6 });
const { emit, updateTime } = setup({ player });

updateTime();

expect(emit).toHaveBeenCalledWith('addProgress', 0.06);
});

it('does not re-record until another 5 seconds have elapsed', () => {
const player = createMockPlayer({ duration: 100, currentTime: 6 });
const { emit, updateTime } = setup({ player });

updateTime();
emit.mockClear();

player.value.currentTime(9);
updateTime();
expect(emit).not.toHaveBeenCalled();

player.value.currentTime(11);
updateTime();
expect(emit).toHaveBeenCalledWith('addProgress', 0.05);
});

it('skips update while seeking', () => {
const player = createMockPlayer({ currentTime: 50, seeking: true });
const { emit, updateTime } = setup({ player });

updateTime();

expect(emit).not.toHaveBeenCalled();
});
});

describe('handleSeek', () => {
it('flushes sub-threshold progress before a seek and rebaselines after it', () => {
const player = createMockPlayer({ duration: 100, currentTime: 0 });
const { emit, updateTime, handleSeek } = setup({ player });

player.value.currentTime(3);
updateTime();
expect(emit).not.toHaveBeenCalled();

player.value.currentTime(80);
handleSeek();
expect(emit).toHaveBeenCalledWith('addProgress', 0.03);

emit.mockClear();
player.value.currentTime(85);
updateTime();
expect(emit).toHaveBeenCalledWith('addProgress', 0.05);
});
});

describe('setPlayState', () => {
it('emits startTracking when state is true', () => {
const player = createMockPlayer();
const { emit, setPlayState } = setup({ player });

setPlayState(true);

expect(emit).toHaveBeenCalledWith('startTracking');
});

it('emits stopTracking when state is false', () => {
const player = createMockPlayer();
const { emit, setPlayState } = setup({ player });

setPlayState(false);

expect(emit).toHaveBeenCalledWith('stopTracking');
});

it('records progress before changing state', () => {
const player = createMockPlayer({ duration: 100 });
const { emit, setPlayState } = setup({
player,
forceDurationBasedProgress: ref(true),
durationBasedProgress: ref(0.5),
});

setPlayState(true);

// recordProgress should be called before startTracking
const calls = emit.mock.calls.map(c => c[0]);
expect(calls.indexOf('updateProgress')).toBeLessThan(calls.indexOf('startTracking'));
});

it('skips recording progress while seeking', () => {
const player = createMockPlayer({ seeking: true });
const { emit, setPlayState } = setup({ player });

setPlayState(true);

// Only startTracking, no progress recording
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith('startTracking');
});
});

describe('updateContentState', () => {
it('emits updateContentState with saved location', () => {
const player = createMockPlayer({ currentTime: 42 });
const { emit, updateContentState } = setup({ player });

updateContentState();

expect(emit).toHaveBeenCalledWith('updateContentState', { savedLocation: 42 });
});

it('preserves existing contentState fields', () => {
const player = createMockPlayer({ currentTime: 42 });
const { emit, updateContentState } = setup({
player,
extraFields: ref({ contentState: { someField: 'value', savedLocation: 10 } }),
});

updateContentState();

expect(emit).toHaveBeenCalledWith('updateContentState', {
someField: 'value',
savedLocation: 42,
});
});

it('does nothing when player is null', () => {
const { emit, updateContentState } = setup({ player: ref(null) });

updateContentState();

expect(emit).not.toHaveBeenCalled();
});
});
});
Loading
Loading