From b38df2fbe3d2fabc59c98c6ceca5e733fff264b4 Mon Sep 17 00:00:00 2001 From: Michael David Kuckuk <8076094+LBBO@users.noreply.github.com> Date: Mon, 27 Feb 2023 01:54:28 +0100 Subject: [PATCH] Create stories for layout testing (#2722) * Inject services with useContext * Extract service for video settings * Create mock factories for services * Create test data for chat history * Add story to visualize different layouts * Fix renaming mistake * Add landscape and portrait viewports * Add landscape stories --------- Co-authored-by: Gabe Kangas --- web/.storybook/preview.js | 67 ++++++++ web/components/layouts/Main/Main.stories.tsx | 158 ++++++++++++++++++ web/components/stores/ClientConfigStore.tsx | 16 +- .../video/OwncastPlayer/OwncastPlayer.tsx | 19 +-- web/interfaces/chat-message.fixture.ts | 62 +++++++ web/interfaces/user.fixture.ts | 15 ++ web/services/chat-service.mock.ts | 18 ++ web/services/chat-service.ts | 10 +- web/services/client-config-service.mock.ts | 11 ++ web/services/client-config-service.ts | 8 +- web/services/status-service.mock.ts | 13 ++ web/services/status-service.ts | 8 +- web/services/video-settings-service.mock.ts | 12 ++ web/services/video-settings-service.ts | 36 ++++ 14 files changed, 428 insertions(+), 25 deletions(-) create mode 100644 web/components/layouts/Main/Main.stories.tsx create mode 100644 web/interfaces/chat-message.fixture.ts create mode 100644 web/interfaces/user.fixture.ts create mode 100644 web/services/chat-service.mock.ts create mode 100644 web/services/client-config-service.mock.ts create mode 100644 web/services/status-service.mock.ts create mode 100644 web/services/video-settings-service.mock.ts create mode 100644 web/services/video-settings-service.ts diff --git a/web/.storybook/preview.js b/web/.storybook/preview.js index c70e71aea..a4f9bbfc1 100644 --- a/web/.storybook/preview.js +++ b/web/.storybook/preview.js @@ -4,6 +4,68 @@ import '../styles/theme.less'; import './preview.scss'; import { themes } from '@storybook/theming'; import { DocsContainer } from './storybook-theme'; +import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; +import _ from 'lodash'; + +/** + * Takes an entry of a viewport (from Object.entries()) and converts it + * into two entries, one for landscape and one for portrait. + * + * @template {string} Key + * + * @param {[Key, import('@storybook/addon-viewport/dist/ts3.9/models').Viewport]} entry + * @returns {Array<[`${Key}${'Portrait' | 'Landscape'}`, import('@storybook/addon-viewport/dist/ts3.9/models').Viewport]>} + */ +const convertToLandscapeAndPortraitEntries = ([objectKey, viewport]) => { + const pixelStringToNumber = str => parseInt(str.split('px')[0]); + const dimensions = [viewport.styles.width, viewport.styles.height].map(pixelStringToNumber); + const minDimension = Math.min(...dimensions); + const maxDimension = Math.max(...dimensions); + + return [ + [ + `${objectKey}Portrait`, + { + ...viewport, + name: viewport.name + ' (Portrait)', + styles: { + ...viewport.styles, + height: maxDimension + 'px', + width: minDimension + 'px', + }, + }, + ], + [ + `${objectKey}Landscape`, + { + ...viewport, + name: viewport.name + ' (Landscape)', + styles: { + ...viewport.styles, + height: minDimension + 'px', + width: maxDimension + 'px', + }, + }, + ], + ]; +}; + +/** + * Takes an object and a function f and returns a new object. + * f takes the original object's entries (key-value-pairs + * from Object.entries) and returns a list of new entries + * (also key-value-pairs). These new entries then form the + * result. + * @template {string | number} OriginalKey + * @template {string | number} NewKey + * @template OriginalValue + * @template OriginalValue + * + * @param {Record} obj + * @param {(entry: [OriginalKey, OriginalValue], index: number, all: Array<[OriginalKey, OriginalValue]>) => Array<[NewKey, NewValue]>} f + * @returns {Record} + */ +const flatMapObject = (obj, f) => Object.fromEntries(Object.entries(obj).flatMap(f)); export const parameters = { fetchMock: { @@ -36,4 +98,9 @@ export const parameters = { // Override the default light theme light: { ...themes.normal }, }, + viewport: { + // Take a bunch of viewports from the storybook addon and convert them + // to portrait + landscape. Keys are appended with 'Landscape' or 'Portrait'. + viewports: flatMapObject(INITIAL_VIEWPORTS, convertToLandscapeAndPortraitEntries), + }, }; diff --git a/web/components/layouts/Main/Main.stories.tsx b/web/components/layouts/Main/Main.stories.tsx new file mode 100644 index 000000000..fc47cc7ca --- /dev/null +++ b/web/components/layouts/Main/Main.stories.tsx @@ -0,0 +1,158 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { MutableSnapshot, RecoilRoot } from 'recoil'; +import { makeEmptyClientConfig } from '../../../interfaces/client-config.model'; +import { ServerStatus, makeEmptyServerStatus } from '../../../interfaces/server-status.model'; +import { + accessTokenAtom, + appStateAtom, + chatMessagesAtom, + chatVisibleToggleAtom, + clientConfigStateAtom, + currentUserAtom, + fatalErrorStateAtom, + isMobileAtom, + isVideoPlayingAtom, + serverStatusState, +} from '../../stores/ClientConfigStore'; +import { Main } from './Main'; +import { ClientConfigServiceContext } from '../../../services/client-config-service'; +import { ChatServiceContext } from '../../../services/chat-service'; +import { + ServerStatusServiceContext, + ServerStatusStaticService, +} from '../../../services/status-service'; +import { clientConfigServiceMockOf } from '../../../services/client-config-service.mock'; +import chatServiceMockOf from '../../../services/chat-service.mock'; +import serverStatusServiceMockOf from '../../../services/status-service.mock'; +import { VideoSettingsServiceContext } from '../../../services/video-settings-service'; +import videoSettingsServiceMockOf from '../../../services/video-settings-service.mock'; +import { grootUser, spidermanUser } from '../../../interfaces/user.fixture'; +import { exampleChatHistory } from '../../../interfaces/chat-message.fixture'; + +export default { + title: 'owncast/Layout/Main', + parameters: { + layout: 'fullscreen', + }, +} satisfies ComponentMeta; + +type StateInitializer = (mutableState: MutableSnapshot) => void; + +const composeStateInitializers = + (...fns: Array): StateInitializer => + state => + fns.forEach(fn => fn?.(state)); + +const defaultClientConfig = { + ...makeEmptyClientConfig(), + logo: 'http://localhost:8080/logo', + name: "Spiderman's super serious stream", + summary: 'Strong Spidey stops supervillains! Streamed Saturdays & Sundays.', + extraPageContent: 'Spiderman is cool', +}; + +const defaultServerStatus = makeEmptyServerStatus(); +const onlineServerStatus: ServerStatus = { + ...defaultServerStatus, + online: true, + viewerCount: 5, +}; + +const initializeDefaultState = (mutableState: MutableSnapshot) => { + mutableState.set(appStateAtom, { + videoAvailable: false, + chatAvailable: false, + }); + mutableState.set(clientConfigStateAtom, defaultClientConfig); + mutableState.set(chatVisibleToggleAtom, true); + mutableState.set(accessTokenAtom, 'token'); + mutableState.set(currentUserAtom, { + ...spidermanUser, + isModerator: false, + }); + mutableState.set(serverStatusState, defaultServerStatus); + mutableState.set(isMobileAtom, false); + + mutableState.set(chatMessagesAtom, exampleChatHistory); + mutableState.set(isVideoPlayingAtom, false); + mutableState.set(fatalErrorStateAtom, null); +}; + +const ClientConfigServiceMock = clientConfigServiceMockOf(defaultClientConfig); +const ChatServiceMock = chatServiceMockOf(exampleChatHistory, { + ...grootUser, + accessToken: 'some fake token', +}); +const DefaultServerStatusServiceMock = serverStatusServiceMockOf(defaultServerStatus); +const OnlineServerStatusServiceMock = serverStatusServiceMockOf(onlineServerStatus); +const VideoSettingsServiceMock = videoSettingsServiceMockOf([]); + +const Template: ComponentStory = ({ + initializeState, + ServerStatusServiceMock = DefaultServerStatusServiceMock, + ...args +}: { + initializeState: (mutableState: MutableSnapshot) => void; + ServerStatusServiceMock: ServerStatusStaticService; +}) => ( + + + + + +
+ + + + + +); + +export const OfflineDesktop: typeof Template = Template.bind({}); + +export const OfflineMobile: typeof Template = Template.bind({}); +OfflineMobile.args = { + initializeState: (mutableState: MutableSnapshot) => { + mutableState.set(isMobileAtom, true); + }, +}; +OfflineMobile.parameters = { + viewport: { + defaultViewport: 'mobile1', + }, +}; + +export const OfflineTablet: typeof Template = Template.bind({}); +OfflineTablet.parameters = { + viewport: { + defaultViewport: 'tablet', + }, +}; + +export const Online: typeof Template = Template.bind({}); +Online.args = { + ServerStatusServiceMock: OnlineServerStatusServiceMock, +}; + +export const OnlineMobile: typeof Template = Online.bind({}); +OnlineMobile.args = { + ServerStatusServiceMock: OnlineServerStatusServiceMock, + initializeState: (mutableState: MutableSnapshot) => { + mutableState.set(isMobileAtom, true); + }, +}; +OnlineMobile.parameters = { + viewport: { + defaultViewport: 'mobile1', + }, +}; + +export const OnlineTablet: typeof Template = Online.bind({}); +OnlineTablet.args = { + ServerStatusServiceMock: OnlineServerStatusServiceMock, +}; +OnlineTablet.parameters = { + viewport: { + defaultViewport: 'tablet', + }, +}; diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index 040bd1431..b86fe0f54 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -1,9 +1,9 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useContext, useEffect, useState } from 'react'; import { atom, selector, useRecoilState, useSetRecoilState, RecoilEnv } from 'recoil'; import { useMachine } from '@xstate/react'; import { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model'; -import ClientConfigService from '../../services/client-config-service'; -import ChatService from '../../services/chat-service'; +import { ClientConfigServiceContext } from '../../services/client-config-service'; +import { ChatServiceContext } from '../../services/chat-service'; import WebsocketService from '../../services/websocket-service'; import { ChatMessage } from '../../interfaces/chat-message.model'; import { CurrentUser } from '../../interfaces/current-user'; @@ -24,7 +24,7 @@ import { } from '../../interfaces/socket-events'; import { mergeMeta } from '../../utils/helpers'; import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler'; -import ServerStatusService from '../../services/status-service'; +import { ServerStatusServiceContext } from '../../services/status-service'; import handleNameChangeEvent from './eventhandlers/handleNameChangeEvent'; import { DisplayableError } from '../../types/displayable-error'; @@ -155,6 +155,10 @@ export const visibleChatMessagesSelector = selector({ }); export const ClientConfigStore: FC = () => { + const ClientConfigService = useContext(ClientConfigServiceContext); + const ChatService = useContext(ChatServiceContext); + const ServerStatusService = useContext(ServerStatusServiceContext); + const [appState, appStateSend, appStateService] = useMachine(appStateModel); const [currentUser, setCurrentUser] = useRecoilState(currentUserAtom); const setChatAuthenticated = useSetRecoilState(chatAuthenticatedAtom); @@ -209,7 +213,7 @@ export const ClientConfigStore: FC = () => { setHasLoadedConfig(true); } catch (error) { setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError); - console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`); + console.error(`ClientConfigService -> getConfig() ERROR: \n`, error); } }; @@ -228,7 +232,7 @@ export const ClientConfigStore: FC = () => { } catch (error) { sendEvent([AppStateEvent.Fail]); setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError); - console.error(`serverStatusState -> getStatus() ERROR: \n${error}`); + console.error(`serverStatusState -> getStatus() ERROR: \n`, error); } }; diff --git a/web/components/video/OwncastPlayer/OwncastPlayer.tsx b/web/components/video/OwncastPlayer/OwncastPlayer.tsx index bd2b88a1f..3c1f929cf 100644 --- a/web/components/video/OwncastPlayer/OwncastPlayer.tsx +++ b/web/components/video/OwncastPlayer/OwncastPlayer.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect } from 'react'; +import React, { FC, useContext, useEffect } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { useHotkeys } from 'react-hotkeys-hook'; import { VideoJsPlayerOptions } from 'video.js'; @@ -12,8 +12,8 @@ import PlaybackMetrics from '../metrics/playback'; import createVideoSettingsMenuButton from '../settings-menu'; import LatencyCompensator from '../latencyCompensator'; import styles from './OwncastPlayer.module.scss'; +import { VideoSettingsServiceContext } from '../../../services/video-settings-service'; -const VIDEO_CONFIG_URL = '/api/video/variants'; const PLAYER_VOLUME = 'owncast_volume'; const LATENCY_COMPENSATION_ENABLED = 'latencyCompensatorEnabled'; @@ -30,18 +30,6 @@ export type OwncastPlayerProps = { className?: string; }; -async function getVideoSettings() { - let qualities = []; - - try { - const response = await fetch(VIDEO_CONFIG_URL); - qualities = await response.json(); - } catch (e) { - console.error(e); - } - return qualities; -} - export const OwncastPlayer: FC = ({ source, online, @@ -49,6 +37,7 @@ export const OwncastPlayer: FC = ({ title, className, }) => { + const VideoSettingsService = useContext(VideoSettingsServiceContext); const playerRef = React.useRef(null); const [videoPlaying, setVideoPlaying] = useRecoilState(isVideoPlayingAtom); const clockSkew = useRecoilValue(clockSkewAtom); @@ -151,7 +140,7 @@ export const OwncastPlayer: FC = ({ }; const createSettings = async (player, videojs) => { - const videoQualities = await getVideoSettings(); + const videoQualities = await VideoSettingsService.getVideoQualities(); const menuButton = createVideoSettingsMenuButton( player, videojs, diff --git a/web/interfaces/chat-message.fixture.ts b/web/interfaces/chat-message.fixture.ts new file mode 100644 index 000000000..631b07312 --- /dev/null +++ b/web/interfaces/chat-message.fixture.ts @@ -0,0 +1,62 @@ +import { ChatMessage } from './chat-message.model'; +import { MessageType } from './socket-events'; +import { spidermanUser, grootUser } from './user.fixture'; +import { User } from './user.model'; + +export const createMessages = ( + basicMessages: Array<{ body: string; user: User }>, +): Array => { + const baseDate = new Date(2022, 1, 3).valueOf(); + return basicMessages.map( + ({ body, user }, index): ChatMessage => ({ + body, + user, + id: index.toString(), + type: MessageType.CHAT, + timestamp: new Date(baseDate + 1_000 * index), + }), + ); +}; + +export const exampleChatHistory = createMessages([ + { + body: 'So, how do you like my new suit?', + user: spidermanUser, + }, + { + body: 'Im am Groot.', + user: grootUser, + }, + { + body: 'Really? That bad?', + user: spidermanUser, + }, + { + body: 'Im am Groot!', + user: grootUser, + }, + { + body: 'But what about the new web slingers?', + user: spidermanUser, + }, + { + body: 'Im am Groooooooooooooooot.', + user: grootUser, + }, + { + body: "Ugh, come on, they aren't THAT big!", + user: spidermanUser, + }, + { + body: 'I am Groot.', + user: grootUser, + }, + { + body: "Fine then. I don't like your new leaves either!", + user: spidermanUser, + }, + { + body: 'I AM GROOT!!!!!', + user: grootUser, + }, +]); diff --git a/web/interfaces/user.fixture.ts b/web/interfaces/user.fixture.ts new file mode 100644 index 000000000..c56f1619d --- /dev/null +++ b/web/interfaces/user.fixture.ts @@ -0,0 +1,15 @@ +import { User } from './user.model'; + +export const createUser = (name: string, color: number, createdAt: Date): User => ({ + id: name, + displayName: name, + displayColor: color, + createdAt, + authenticated: true, + nameChangedAt: createdAt, + previousNames: [], + scopes: [], +}); + +export const spidermanUser = createUser('Spiderman', 1, new Date(2020, 1, 2)); +export const grootUser = createUser('Groot', 1, new Date(2020, 2, 3)); diff --git a/web/services/chat-service.mock.ts b/web/services/chat-service.mock.ts new file mode 100644 index 000000000..ea2b6c7b3 --- /dev/null +++ b/web/services/chat-service.mock.ts @@ -0,0 +1,18 @@ +import { ChatMessage } from '../interfaces/chat-message.model'; +import { ChatStaticService, UserRegistrationResponse } from './chat-service'; + +export const chatServiceMockOf = ( + chatHistory: ChatMessage[], + userRegistrationResponse: UserRegistrationResponse, +): ChatStaticService => + class ChatServiceMock { + public static async getChatHistory(): Promise { + return chatHistory; + } + + public static async registerUser(): Promise { + return userRegistrationResponse; + } + }; + +export default chatServiceMockOf; diff --git a/web/services/chat-service.ts b/web/services/chat-service.ts index b287fc83f..edf433a55 100644 --- a/web/services/chat-service.ts +++ b/web/services/chat-service.ts @@ -1,16 +1,22 @@ +import { createContext } from 'react'; import { ChatMessage } from '../interfaces/chat-message.model'; import { getUnauthedData } from '../utils/apis'; const ENDPOINT = `/api/chat`; const URL_CHAT_REGISTRATION = `/api/chat/register`; -interface UserRegistrationResponse { +export interface UserRegistrationResponse { id: string; accessToken: string; displayName: string; displayColor: number; } +export interface ChatStaticService { + getChatHistory(accessToken: string): Promise; + registerUser(username: string): Promise; +} + class ChatService { public static async getChatHistory(accessToken: string): Promise { const response = await getUnauthedData(`${ENDPOINT}?accessToken=${accessToken}`); @@ -31,4 +37,4 @@ class ChatService { } } -export default ChatService; +export const ChatServiceContext = createContext(ChatService); diff --git a/web/services/client-config-service.mock.ts b/web/services/client-config-service.mock.ts new file mode 100644 index 000000000..262400b09 --- /dev/null +++ b/web/services/client-config-service.mock.ts @@ -0,0 +1,11 @@ +import { ClientConfig } from '../interfaces/client-config.model'; +import { ClientConfigStaticService } from './client-config-service'; + +export const clientConfigServiceMockOf = (config: ClientConfig): ClientConfigStaticService => + class ClientConfigServiceMock { + public static async getConfig(): Promise { + return config; + } + }; + +export default clientConfigServiceMockOf; diff --git a/web/services/client-config-service.ts b/web/services/client-config-service.ts index 2b47b8d52..5f4d36dfb 100644 --- a/web/services/client-config-service.ts +++ b/web/services/client-config-service.ts @@ -1,7 +1,12 @@ +import { createContext } from 'react'; import { ClientConfig } from '../interfaces/client-config.model'; const ENDPOINT = `/api/config`; +export interface ClientConfigStaticService { + getConfig(): Promise; +} + class ClientConfigService { public static async getConfig(): Promise { const response = await fetch(ENDPOINT); @@ -10,4 +15,5 @@ class ClientConfigService { } } -export default ClientConfigService; +export const ClientConfigServiceContext = + createContext(ClientConfigService); diff --git a/web/services/status-service.mock.ts b/web/services/status-service.mock.ts new file mode 100644 index 000000000..573e7ffa6 --- /dev/null +++ b/web/services/status-service.mock.ts @@ -0,0 +1,13 @@ +import { ServerStatus } from '../interfaces/server-status.model'; +import { ServerStatusStaticService } from './status-service'; + +export const serverStatusServiceMockOf = ( + serverStatus: ServerStatus, +): ServerStatusStaticService => + class ServerStatusServiceMock { + public static async getStatus(): Promise { + return serverStatus; + } + }; + +export default serverStatusServiceMockOf; diff --git a/web/services/status-service.ts b/web/services/status-service.ts index 995d5206f..e41706754 100644 --- a/web/services/status-service.ts +++ b/web/services/status-service.ts @@ -1,7 +1,12 @@ +import { createContext } from 'react'; import { ServerStatus } from '../interfaces/server-status.model'; const ENDPOINT = `/api/status`; +export interface ServerStatusStaticService { + getStatus(): Promise; +} + class ServerStatusService { public static async getStatus(): Promise { const response = await fetch(ENDPOINT); @@ -10,4 +15,5 @@ class ServerStatusService { } } -export default ServerStatusService; +export const ServerStatusServiceContext = + createContext(ServerStatusService); diff --git a/web/services/video-settings-service.mock.ts b/web/services/video-settings-service.mock.ts new file mode 100644 index 000000000..0a3d24fac --- /dev/null +++ b/web/services/video-settings-service.mock.ts @@ -0,0 +1,12 @@ +import { VideoSettingsStaticService, VideoQuality } from './video-settings-service'; + +export const videoSettingsServiceMockOf = ( + videoQualities: Array, +): VideoSettingsStaticService => + class VideoSettingsServiceMock { + public static async getVideoQualities(): Promise> { + return videoQualities; + } + }; + +export default videoSettingsServiceMockOf; diff --git a/web/services/video-settings-service.ts b/web/services/video-settings-service.ts new file mode 100644 index 000000000..7a64c90ea --- /dev/null +++ b/web/services/video-settings-service.ts @@ -0,0 +1,36 @@ +import { createContext } from 'react'; + +export type VideoQuality = { + index: number; + /** + * This property is not just for display or so + * but it holds information + * + * @example '1.2Mbps@24fps' + */ + name: string; +}; + +export interface VideoSettingsStaticService { + getVideoQualities(): Promise>; +} + +class VideoSettingsService { + private static readonly VIDEO_CONFIG_URL = '/api/video/variants'; + + public static async getVideoQualities(): Promise> { + let qualities: Array = []; + + try { + const response = await fetch(VideoSettingsService.VIDEO_CONFIG_URL); + qualities = await response.json(); + console.log(qualities); + } catch (e) { + console.error(e); + } + return qualities; + } +} + +export const VideoSettingsServiceContext = + createContext(VideoSettingsService);