The commit adds eventlistener which picks up the autoAdvance message and triggers the next sequence. This has the same effect of clicking the next button. Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
413 lines
15 KiB
JavaScript
413 lines
15 KiB
JavaScript
import React from 'react';
|
|
import { useDispatch } from 'react-redux';
|
|
|
|
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
|
|
import { logError } from '@edx/frontend-platform/logging';
|
|
|
|
import { getConfig } from '@edx/frontend-platform';
|
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
|
import { fetchCourse } from '@src/courseware/data';
|
|
import { processEvent } from '@src/course-home/data/thunks';
|
|
import { useEventListener } from '@src/generic/hooks';
|
|
import { useSequenceNavigationMetadata } from '@src/courseware/course/sequence/sequence-navigation/hooks';
|
|
|
|
import { messageTypes } from '../constants';
|
|
|
|
import useIFrameBehavior, { stateKeys } from './useIFrameBehavior';
|
|
|
|
const mockNavigate = jest.fn();
|
|
|
|
jest.mock('@edx/frontend-platform', () => ({
|
|
getConfig: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@edx/frontend-platform/analytics');
|
|
|
|
jest.mock('react', () => ({
|
|
...jest.requireActual('react'),
|
|
useEffect: jest.fn(),
|
|
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
|
|
}));
|
|
|
|
jest.mock('react-redux', () => ({
|
|
useDispatch: jest.fn(),
|
|
useSelector: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('lodash', () => ({
|
|
...jest.requireActual('lodash'),
|
|
throttle: jest.fn((fn) => fn),
|
|
}));
|
|
|
|
jest.mock('./useLoadBearingHook', () => jest.fn());
|
|
|
|
jest.mock('@edx/frontend-platform/logging', () => ({
|
|
logError: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@src/courseware/data', () => ({
|
|
fetchCourse: jest.fn(),
|
|
}));
|
|
jest.mock('@src/course-home/data/thunks', () => ({
|
|
processEvent: jest.fn((...args) => ({ processEvent: args })),
|
|
}));
|
|
jest.mock('@src/generic/hooks', () => ({
|
|
useEventListener: jest.fn(),
|
|
}));
|
|
jest.mock('@src/generic/model-store', () => ({
|
|
useModel: () => ({ unitIds: ['unit1', 'unit2'], entranceExamData: { entranceExamPassed: null } }),
|
|
}));
|
|
jest.mock('react-router-dom', () => ({
|
|
...jest.requireActual('react-router-dom'),
|
|
useNavigate: () => mockNavigate,
|
|
}));
|
|
|
|
jest.mock('@src/courseware/course/sequence/sequence-navigation/hooks');
|
|
useSequenceNavigationMetadata.mockReturnValue({ isLastUnit: false, nextLink: '/next-unit-link' });
|
|
|
|
const state = mockUseKeyedState(stateKeys);
|
|
|
|
const props = {
|
|
elementId: 'test-element-id',
|
|
id: 'test-id',
|
|
iframeUrl: 'test-iframe-url',
|
|
onLoaded: jest.fn(),
|
|
};
|
|
|
|
const testIFrameHeight = 42;
|
|
|
|
const config = { LMS_BASE_URL: 'test-base-url' };
|
|
getConfig.mockReturnValue(config);
|
|
|
|
const dispatch = jest.fn();
|
|
useDispatch.mockReturnValue(dispatch);
|
|
|
|
const postMessage = jest.fn();
|
|
const frame = {
|
|
contentWindow: { postMessage },
|
|
getBoundingClientRect: jest.fn(() => ({ top: 100 })),
|
|
};
|
|
const mockGetElementById = jest.fn(() => frame);
|
|
const testHash = '#test-hash';
|
|
|
|
const defaultStateVals = {
|
|
iframeHeight: 0,
|
|
hasLoaded: false,
|
|
showError: false,
|
|
windowTopOffset: null,
|
|
};
|
|
|
|
const stateVals = {
|
|
iframeHeight: testIFrameHeight,
|
|
hasLoaded: true,
|
|
showError: true,
|
|
windowTopOffset: 32,
|
|
};
|
|
|
|
describe('useIFrameBehavior hook', () => {
|
|
let hook;
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
state.mock();
|
|
global.document.getElementById = mockGetElementById;
|
|
global.window.addEventListener = jest.fn();
|
|
global.window.removeEventListener = jest.fn();
|
|
global.window.innerHeight = 800;
|
|
});
|
|
afterEach(() => {
|
|
state.resetVals();
|
|
});
|
|
describe('behavior', () => {
|
|
it('initializes iframe height to 0 and error/loaded values to false', () => {
|
|
hook = useIFrameBehavior(props);
|
|
state.expectInitializedWith(stateKeys.iframeHeight, 0);
|
|
state.expectInitializedWith(stateKeys.hasLoaded, false);
|
|
state.expectInitializedWith(stateKeys.showError, false);
|
|
state.expectInitializedWith(stateKeys.windowTopOffset, null);
|
|
});
|
|
describe('effects - on frame change', () => {
|
|
let oldGetElement;
|
|
beforeEach(() => {
|
|
global.window ??= Object.create(window);
|
|
Object.defineProperty(window, 'location', { value: {}, writable: true });
|
|
state.mockVals(stateVals);
|
|
oldGetElement = document.getElementById;
|
|
document.getElementById = mockGetElementById;
|
|
});
|
|
afterEach(() => {
|
|
state.resetVals();
|
|
document.getElementById = oldGetElement;
|
|
});
|
|
it('does not post url hash if the window does not have one', () => {
|
|
hook = useIFrameBehavior(props);
|
|
const cb = getEffects([
|
|
props.id,
|
|
props.onLoaded,
|
|
testIFrameHeight,
|
|
true,
|
|
], React)[0];
|
|
cb();
|
|
expect(postMessage).not.toHaveBeenCalled();
|
|
});
|
|
it('posts url hash if the window has one', () => {
|
|
window.location.hash = testHash;
|
|
hook = useIFrameBehavior(props);
|
|
const cb = getEffects([
|
|
props.id,
|
|
props.onLoaded,
|
|
testIFrameHeight,
|
|
true,
|
|
], React)[0];
|
|
cb();
|
|
expect(postMessage).toHaveBeenCalledWith({ hashName: testHash }, config.LMS_BASE_URL);
|
|
});
|
|
});
|
|
describe('event listener', () => {
|
|
it('calls eventListener with prepared callback', () => {
|
|
state.mockVals(stateVals);
|
|
hook = useIFrameBehavior(props);
|
|
const [call] = useEventListener.mock.calls;
|
|
expect(call[0]).toEqual('message');
|
|
expect(call[1].prereqs).toEqual([
|
|
props.id,
|
|
props.onLoaded,
|
|
state.values.hasLoaded,
|
|
state.setState.hasLoaded,
|
|
state.values.iframeHeight,
|
|
state.setState.iframeHeight,
|
|
state.values.windowTopOffset,
|
|
state.setState.windowTopOffset,
|
|
]);
|
|
});
|
|
describe('resize message', () => {
|
|
const resizeMessage = (height = 23) => ({
|
|
data: { type: messageTypes.resize, payload: { height } },
|
|
});
|
|
const videoFullScreenMessage = (open = false) => ({
|
|
data: { type: messageTypes.videoFullScreen, payload: { open } },
|
|
});
|
|
const testSetIFrameHeight = (height = 23) => {
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
cb(resizeMessage(height));
|
|
expect(state.setState.iframeHeight).toHaveBeenCalledWith(height);
|
|
};
|
|
const testOnlySetsHeight = () => {
|
|
it('sets iframe height with payload height', () => {
|
|
testSetIFrameHeight();
|
|
});
|
|
it('does not set hasLoaded', () => {
|
|
expect(state.setState.hasLoaded).not.toHaveBeenCalled();
|
|
});
|
|
};
|
|
describe('hasLoaded', () => {
|
|
beforeEach(() => {
|
|
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
|
hook = useIFrameBehavior(props);
|
|
});
|
|
testOnlySetsHeight();
|
|
});
|
|
describe('iframeHeight is not 0', () => {
|
|
beforeEach(() => {
|
|
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
|
hook = useIFrameBehavior(props);
|
|
});
|
|
testOnlySetsHeight();
|
|
});
|
|
describe('payload height is 0', () => {
|
|
beforeEach(() => { hook = useIFrameBehavior(props); });
|
|
testOnlySetsHeight(0);
|
|
});
|
|
describe('payload is present but uninitialized', () => {
|
|
it('sets iframe height with payload height', () => {
|
|
hook = useIFrameBehavior(props);
|
|
testSetIFrameHeight();
|
|
});
|
|
it('sets hasLoaded and calls onLoaded', () => {
|
|
hook = useIFrameBehavior(props);
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
cb(resizeMessage());
|
|
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
|
|
expect(props.onLoaded).toHaveBeenCalled();
|
|
});
|
|
test('onLoaded is optional', () => {
|
|
hook = useIFrameBehavior({ ...props, onLoaded: undefined });
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
cb(resizeMessage());
|
|
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
it('scrolls to current window vertical offset if one is set', () => {
|
|
const windowTopOffset = 32;
|
|
state.mockVals({ ...defaultStateVals, windowTopOffset });
|
|
hook = useIFrameBehavior(props);
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
cb(videoFullScreenMessage());
|
|
expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset);
|
|
});
|
|
it('does not scroll if towverticalp offset is not set', () => {
|
|
hook = useIFrameBehavior(props);
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
cb(resizeMessage());
|
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
describe('video fullscreen message', () => {
|
|
let cb;
|
|
const scrollY = 23;
|
|
const fullScreenMessage = (open) => ({
|
|
data: { type: messageTypes.videoFullScreen, payload: { open } },
|
|
});
|
|
beforeEach(() => {
|
|
window.scrollY = scrollY;
|
|
hook = useIFrameBehavior(props);
|
|
[[, { cb }]] = useEventListener.mock.calls;
|
|
});
|
|
it('sets window top offset based on window.scrollY if opening the video', () => {
|
|
cb(fullScreenMessage(true));
|
|
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(scrollY);
|
|
});
|
|
it('sets window top offset to null if closing the video', () => {
|
|
cb(fullScreenMessage(false));
|
|
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(null);
|
|
});
|
|
});
|
|
describe('offset message', () => {
|
|
it('scrolls to data offset', () => {
|
|
const offsetTop = 44;
|
|
const mockGetEl = jest.fn(() => ({ offsetTop }));
|
|
|
|
const oldGetElement = document.getElementById;
|
|
document.getElementById = mockGetEl;
|
|
const oldScrollTo = window.scrollTo;
|
|
window.scrollTo = jest.fn();
|
|
hook = useIFrameBehavior(props);
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
const offset = 99;
|
|
cb({ data: { offset } });
|
|
expect(window.scrollTo).toHaveBeenCalledWith(0, offset + offsetTop);
|
|
expect(mockGetEl).toHaveBeenCalledWith('unit-iframe');
|
|
document.getElementById = oldGetElement;
|
|
window.scrollTo = oldScrollTo;
|
|
});
|
|
});
|
|
});
|
|
describe('visibility tracking', () => {
|
|
it('sets up visibility tracking after iframe has loaded', () => {
|
|
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
|
useIFrameBehavior(props);
|
|
|
|
const effects = getEffects([true, props.elementId], React);
|
|
expect(effects.length).toEqual(2);
|
|
effects[0](); // Execute the visibility tracking effect.
|
|
|
|
expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
|
|
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
|
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
|
// Initial visibility update.
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: 'unit.visibilityStatus',
|
|
data: {
|
|
topPosition: 100,
|
|
viewportHeight: 800,
|
|
},
|
|
},
|
|
config.LMS_BASE_URL,
|
|
);
|
|
});
|
|
it('does not set up visibility tracking before iframe has loaded', () => {
|
|
state.mockVals({ ...defaultStateVals, hasLoaded: false });
|
|
useIFrameBehavior(props);
|
|
|
|
const effects = getEffects([false, props.elementId], React);
|
|
expect(effects).toBeNull();
|
|
|
|
expect(global.window.addEventListener).not.toHaveBeenCalled();
|
|
expect(postMessage).not.toHaveBeenCalled();
|
|
});
|
|
it('cleans up event listeners on unmount', () => {
|
|
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
|
useIFrameBehavior(props);
|
|
|
|
const effects = getEffects([true, props.elementId], React);
|
|
const cleanup = effects[0](); // Execute the effect and get the cleanup function.
|
|
cleanup(); // Call the cleanup function.
|
|
|
|
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
|
|
expect(global.window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
|
expect(global.window.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
|
});
|
|
});
|
|
});
|
|
describe('output', () => {
|
|
describe('handleIFrameLoad', () => {
|
|
it('sets and logs error if has not loaded', () => {
|
|
hook = useIFrameBehavior(props);
|
|
hook.handleIFrameLoad();
|
|
expect(state.setState.showError).toHaveBeenCalledWith(true);
|
|
expect(logError).toHaveBeenCalled();
|
|
});
|
|
it('sends track event if has not loaded', () => {
|
|
hook = useIFrameBehavior(props);
|
|
hook.handleIFrameLoad();
|
|
const eventName = 'edx.bi.error.learning.iframe_load_failed';
|
|
const eventProperties = {
|
|
unitId: props.id,
|
|
iframeUrl: props.iframeUrl,
|
|
};
|
|
expect(sendTrackEvent).toHaveBeenCalledWith(eventName, eventProperties);
|
|
});
|
|
it('does not set/log errors if loaded', () => {
|
|
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
|
hook = useIFrameBehavior(props);
|
|
hook.handleIFrameLoad();
|
|
expect(state.setState.showError).not.toHaveBeenCalled();
|
|
expect(logError).not.toHaveBeenCalled();
|
|
});
|
|
it('does not send track event if loaded', () => {
|
|
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
|
hook = useIFrameBehavior(props);
|
|
hook.handleIFrameLoad();
|
|
expect(sendTrackEvent).not.toHaveBeenCalled();
|
|
});
|
|
it('registers an event handler to process fetchCourse events.', () => {
|
|
hook = useIFrameBehavior(props);
|
|
hook.handleIFrameLoad();
|
|
const eventName = 'test-event-name';
|
|
const event = { data: { event_name: eventName } };
|
|
window.onmessage(event);
|
|
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
|
|
});
|
|
});
|
|
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
|
|
state.mockVals(stateVals);
|
|
hook = useIFrameBehavior(props);
|
|
expect(hook.iframeHeight).toEqual(stateVals.iframeHeight);
|
|
expect(hook.showError).toEqual(stateVals.showError);
|
|
expect(hook.hasLoaded).toEqual(stateVals.hasLoaded);
|
|
});
|
|
});
|
|
describe('navigate link for the next unit on auto advance', () => {
|
|
it('test for link when it is not last unit', () => {
|
|
hook = useIFrameBehavior(props);
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
const autoAdvanceMessage = () => ({
|
|
data: { type: messageTypes.autoAdvance },
|
|
});
|
|
cb(autoAdvanceMessage());
|
|
expect(mockNavigate).toHaveBeenCalledWith('/next-unit-link');
|
|
});
|
|
it('test for link when it is last unit', () => {
|
|
useSequenceNavigationMetadata.mockReset();
|
|
useSequenceNavigationMetadata.mockReturnValue({ isLastUnit: true, nextLink: '/next-unit-link' });
|
|
hook = useIFrameBehavior(props);
|
|
const { cb } = useEventListener.mock.calls[0][1];
|
|
const autoAdvanceMessage = () => ({
|
|
data: { type: messageTypes.autoAdvance },
|
|
});
|
|
cb(autoAdvanceMessage());
|
|
expect(mockNavigate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|