Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix deck state transitions. Add test cases for deck state reducer. #1290

Merged
merged 5 commits into from
Jul 24, 2023
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
5 changes: 5 additions & 0 deletions .changeset/heavy-knives-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'spectacle': patch
---

Fixed deck transitions for presenter mode, added test coverage around deck reducer.
11 changes: 9 additions & 2 deletions packages/spectacle/src/components/presenter-mode/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { useRef, useCallback, useState, useEffect, ReactNode } from 'react';
import {
useRef,
useCallback,
useState,
useEffect,
ReactNode,
ReactElement
} from 'react';
import styled from 'styled-components';
import { DeckInternal, DeckRef, TemplateFn } from '../deck/deck';
import { Text, SpectacleLogo } from '../../index';
Expand Down Expand Up @@ -29,7 +36,7 @@ const PreviewSlideWrapper = styled.div<{ visible?: boolean }>(
})
);

const PresenterMode = (props: PresenterModeProps): JSX.Element => {
const PresenterMode = (props: PresenterModeProps): ReactElement => {
const { children, theme, backgroundImage, template } = props;
const deck = useRef<DeckRef>(null);
const previewDeck = useRef<DeckRef>(null);
Expand Down
153 changes: 153 additions & 0 deletions packages/spectacle/src/hooks/use-deck-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { renderHook, act } from '@testing-library/react';
import useDeckState, { DeckView } from './use-deck-state';

describe('useDeckState', () => {
const initialState: DeckView = {
slideIndex: 1,
stepIndex: 1
};

/**
* The INITIALIZE_TO should set the active and pending views
* to the values provided in the payload.
*/
it('should handle INITIALIZE_TO action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.initializeTo({ slideIndex: 2, stepIndex: 2 });
});

expect(result.current.activeView).toEqual({ slideIndex: 2, stepIndex: 2 });
expect(result.current.pendingView).toEqual({ slideIndex: 2, stepIndex: 2 });
expect(result.current.initialized).toBe(true);
});

/**
* The SKIP_TO action should set the pending view slide index to
* the slide index provided by the payload and set the navigation
* direction based on a delta of the previous and pending slides.
*/
it('should handle SKIP_TO action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.skipTo({ slideIndex: 3 });
});

expect(result.current.navigationDirection).toBe(1);
expect(result.current.pendingView.slideIndex).toBe(3);
});

it('should handle SKIP_TO action in reverse', () => {
const { result } = renderHook(() =>
useDeckState({ slideIndex: 5, stepIndex: 0, slideId: 0 })
);

act(() => {
result.current.skipTo({ slideIndex: 3 });
});

expect(result.current.navigationDirection).toBe(-1);
expect(result.current.pendingView.slideIndex).toBe(3);
});

/**
* The STEP_FORWARD action should increment the pending slide index by 1
* and have a positive navigation direction.
*/
it('should handle STEP_FORWARD action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.stepForward();
});

expect(result.current.pendingView.stepIndex).toBe(
initialState.stepIndex + 1
);
expect(result.current.navigationDirection).toBe(1);
});

/**
* The STEP_FORWARD action should decrement the pending slide index by 1
* and have a negative navigation direction.
*/
it('should handle STEP_BACKWARD action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.stepBackward();
});

expect(result.current.pendingView.stepIndex).toBe(
initialState.stepIndex - 1
);
expect(result.current.navigationDirection).toBe(-1);
});

/**
* The ADVANCE_SLIDE action should increment the pending slide index by 1,
* reset the step index, and have a positive navigation direction.
*/
it('should handle ADVANCE_SLIDE action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.advanceSlide();
});

expect(result.current.pendingView.slideIndex).toBe(
initialState.slideIndex + 1
);
expect(result.current.pendingView.stepIndex).toBe(0);
expect(result.current.navigationDirection).toBe(1);
});

/**
* The REGRESS_SLIDE action should decrement the pending slide index by 1,
* reset the step index, and have a negative navigation direction.
*/
it('should handle REGRESS_SLIDE action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.regressSlide({ stepIndex: 0 });
});

expect(result.current.pendingView.slideIndex).toBe(
initialState.slideIndex - 1
);
expect(result.current.pendingView.stepIndex).toBe(0);
expect(result.current.navigationDirection).toBe(-1);
});

/**
* The COMMIT_TRANSITION action should set the active slide view
* and pending slide view to the payload values.
*/
it('should handle COMMIT_TRANSITION action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.commitTransition({ slideIndex: 2, stepIndex: 2 });
});

expect(result.current.activeView).toEqual({ slideIndex: 2, stepIndex: 2 });
expect(result.current.pendingView).toEqual({ slideIndex: 2, stepIndex: 2 });
});

/**
* The CANCEL_TRANSITION action should cancel the slide transition
* by reverting the pending view values to what the current active slide values are.
*/
it('should handle CANCEL_TRANSITION action', () => {
const { result } = renderHook(() => useDeckState(initialState));

act(() => {
result.current.cancelTransition();
});

expect(result.current.pendingView).toEqual(result.current.activeView);
});
});
22 changes: 19 additions & 3 deletions packages/spectacle/src/hooks/use-deck-state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useReducer, useMemo } from 'react';
import { merge } from 'merge-anything';
import { SlideId } from '../components/deck/deck';
import clamp from '../utils/clamp';

export const GOTO_FINAL_STEP = null as unknown as number;

Expand All @@ -16,7 +17,7 @@ export type DeckState = {
pendingView: DeckView;
};

const initialDeckState: DeckState = {
export const initialDeckState: DeckState = {
initialized: false,
navigationDirection: 0,
pendingView: {
Expand Down Expand Up @@ -49,8 +50,15 @@ function deckReducer(state: DeckState, { type, payload = {} }: ReducerActions) {
initialized: true
};
case 'SKIP_TO':
const navigationDirection = (() => {
if ('slideIndex' in payload && payload.slideIndex) {
return clamp(payload.slideIndex - state.activeView.slideIndex, -1, 1);
}
return null;
})();
return {
...state,
navigationDirection: navigationDirection || state.navigationDirection,
pendingView: merge(state.pendingView, payload)
};
case 'STEP_FORWARD':
Expand Down Expand Up @@ -109,8 +117,16 @@ export default function useDeckState(userProvidedInitialState: DeckView) {
{ initialized, navigationDirection, pendingView, activeView },
dispatch
] = useReducer(deckReducer, {
...initialDeckState,
...userProvidedInitialState
initialized: initialDeckState.initialized,
navigationDirection: initialDeckState.navigationDirection,
pendingView: {
...initialDeckState.pendingView,
...userProvidedInitialState
},
activeView: {
...initialDeckState.activeView,
...userProvidedInitialState
}
});
const actions = useMemo(
() => ({
Expand Down
38 changes: 38 additions & 0 deletions packages/spectacle/src/utils/clamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import clamp, { toFiniteNumber } from './clamp';

describe('toFiniteNumber', () => {
it('should return 0 for NaN', () => {
expect(toFiniteNumber(NaN)).toBe(0);
expect(toFiniteNumber(Number.NaN)).toBe(0);
});

it('should convert finite values to finite numbers', () => {
expect(toFiniteNumber(123)).toBe(123);
expect(toFiniteNumber(-456.789)).toBe(-456.789);
});

it('should return Number.MAX_SAFE_INTEGER for Infinity', () => {
expect(toFiniteNumber(Infinity)).toBe(Number.MAX_SAFE_INTEGER);
expect(toFiniteNumber(-Infinity)).toBe(-Number.MAX_SAFE_INTEGER);
});
});

describe('clamp', () => {
it('should return NaN for NaN input', () => {
expect(clamp(NaN)).toBeNaN();
});

it('should clamp value to specified range', () => {
expect(clamp(10, 0, 5)).toBe(5);
expect(clamp(-3, -10, 0)).toBe(-3);
expect(clamp(7, 5, 10)).toBe(7);
expect(clamp(-15, -10, 10)).toBe(-10);
});

it('should not clamp when range is not specified', () => {
expect(clamp(10)).toBe(10);
expect(clamp(-3)).toBe(-3);
expect(clamp(7)).toBe(7);
expect(clamp(-15)).toBe(-15);
});
});
Loading