diff --git a/frontend/src/components/Block/Block.test.jsx b/frontend/src/components/Block/Block.test.tsx similarity index 98% rename from frontend/src/components/Block/Block.test.jsx rename to frontend/src/components/Block/Block.test.tsx index 2e36c4998..7be132e69 100644 --- a/frontend/src/components/Block/Block.test.jsx +++ b/frontend/src/components/Block/Block.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Route, MemoryRouter, Routes } from 'react-router-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, vi } from 'vitest'; diff --git a/frontend/src/components/FeedbackForm/FeedbackForm.tsx b/frontend/src/components/FeedbackForm/FeedbackForm.tsx index 869dc92e5..35a8018b4 100644 --- a/frontend/src/components/FeedbackForm/FeedbackForm.tsx +++ b/frontend/src/components/FeedbackForm/FeedbackForm.tsx @@ -3,6 +3,7 @@ import { useState, useRef } from "react"; import Question from "../Question/Question"; import Button from "../Button/Button"; import IQuestion from "@/types/Question"; +import { OnResultType } from "@/hooks/useResultHandler"; interface FeedbackFormProps { formActive: boolean; @@ -10,7 +11,7 @@ interface FeedbackFormProps { buttonLabel: string; skipLabel: string; isSkippable: boolean; - onResult: (result: any) => void; + onResult: OnResultType emphasizeTitle?: boolean; } diff --git a/frontend/src/components/HTML/HTML.test.tsx b/frontend/src/components/HTML/HTML.test.tsx new file mode 100644 index 000000000..ddf5af0da --- /dev/null +++ b/frontend/src/components/HTML/HTML.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import HTML from './HTML'; + +describe('HTML', () => { + it('renders the HTML content correctly', () => { + const htmlContent = '

Test content

'; + const { container } = render(); + expect(container.innerHTML).to.include(htmlContent); + }); + + it('applies the default inner className', () => { + const { container } = render(); + const innerDiv = container.querySelector('.html-content'); + + if (!innerDiv) { + throw new Error('Inner div not found'); + } + + expect(innerDiv.classList.contains('text-center')).toBe(true); + expect(innerDiv.classList.contains('pb-3')).toBe(true); + }); + + it('applies a custom inner className', () => { + const customClass = 'custom-class'; + const { container } = render(); + const innerDiv = container.querySelector('.html-content'); + + if (!innerDiv) { + throw new Error('Inner div not found'); + } + + expect(innerDiv.classList.contains(customClass)).toBe(true); + expect(innerDiv.classList.contains('text-center')).toBe(false); + expect(innerDiv.classList.contains('pb-3')).toBe(false); + }); + + it('renders complex HTML content', () => { + const complexHTML = ` +
+

Title

+

Paragraph with bold text

+ +
+ `; + const { container } = render(); + expect(container.innerHTML).to.include(complexHTML); + }); + + it('renders the outer aha__HTML class', () => { + const { container } = render(); + const outerDiv = container.firstChild; + + if (!outerDiv) { + throw new Error('Outer div not found'); + } + + expect(outerDiv.classList.contains('aha__HTML')).toBe(true); + }); + + it('handles empty body content', () => { + const { container } = render(); + const innerDiv = container.querySelector('.html-content'); + + if (!innerDiv) { + throw new Error('Inner div not found'); + } + + expect(innerDiv.innerHTML).to.equal(''); + }); + + it('handles TrustedHTML type for body prop', () => { + const trustedHTML = { + toString: () => '

Trusted HTML

', + } as TrustedHTML; + const { container } = render(); + expect(container.innerHTML).to.include('

Trusted HTML

'); + }); +}); diff --git a/frontend/src/components/HTML/HTML.jsx b/frontend/src/components/HTML/HTML.tsx similarity index 69% rename from frontend/src/components/HTML/HTML.jsx rename to frontend/src/components/HTML/HTML.tsx index b39154c4c..9aeb32e44 100644 --- a/frontend/src/components/HTML/HTML.jsx +++ b/frontend/src/components/HTML/HTML.tsx @@ -1,9 +1,12 @@ -import React from "react"; import classNames from "classnames"; +interface HTMLProps { + body: string | TrustedHTML; + innerClassName?: string; +} /** HTML is an block view, that shows custom HTML and a Form */ -const HTML = ({ body, innerClassName = "text-center pb-3" }) => { +const HTML = ({ body, innerClassName = "text-center pb-3" }: HTMLProps) => { return (
diff --git a/frontend/src/components/Histogram/Histogram.test.tsx b/frontend/src/components/Histogram/Histogram.test.tsx new file mode 100644 index 000000000..224153ad7 --- /dev/null +++ b/frontend/src/components/Histogram/Histogram.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import Histogram from './Histogram'; + +describe('Histogram', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('renders the correct number of bars', () => { + const { container } = render(); + const bars = container.querySelectorAll('.aha__histogram > div'); + expect(bars.length).to.equal(5); + }); + + it('applies the correct spacing between bars', () => { + const { container } = render(); + const bars = container.querySelectorAll('.aha__histogram > div'); + expect(getComputedStyle(bars[0]).marginRight).to.equal('10px'); + expect(getComputedStyle(bars[1]).marginRight).to.equal('10px'); + expect(getComputedStyle(bars[2]).marginRight).to.equal('0px'); + }); + + it('applies the correct margin and background color', () => { + const { container } = render( + + ); + const histogram = container.querySelector('.aha__histogram'); + + if (!histogram) { + throw new Error('Histogram not found'); + } + + expect(getComputedStyle(histogram).marginLeft).to.equal('20px'); + expect(getComputedStyle(histogram).marginTop).to.equal('10px'); + expect(getComputedStyle(histogram).backgroundColor).to.equal('red'); + }); + + it('applies the correct border radius', () => { + const { container } = render(); + const histogram = container.querySelector('.aha__histogram'); + + if (!histogram) { + throw new Error('Histogram not found'); + } + + expect(getComputedStyle(histogram).borderRadius).to.equal('5px'); + }); + + it('has active class when running', () => { + const { container } = render(); + const histogram = container.querySelector('.aha__histogram'); + + if (!histogram) { + throw new Error('Histogram not found'); + } + + expect(histogram.classList.contains('active')).toBe(true); + }); + + it('does not have active class when not running', () => { + const { container } = render(); + const histogram = container.querySelector('.aha__histogram'); + + if (!histogram) { + throw new Error('Histogram not found'); + } + + expect(histogram.classList.contains('active')).toBe(false); + }); + + it('updates bar heights when running', async () => { + const { container, rerender } = render(); + const getHeights = () => Array.from(container.querySelectorAll('.aha__histogram > div')).map( + (bar) => bar.style.height + ); + + const initialHeights = getHeights(); + + // Advance timer and force re-render + vi.advanceTimersByTime(100); + rerender(); + + const updatedHeights = getHeights(); + + expect(initialHeights).not.to.deep.equal(updatedHeights); + }); + + it('does not update bar heights when not running', () => { + const { container, rerender } = render(); + const getHeights = () => Array.from(container.querySelectorAll('.aha__histogram > div')).map( + (bar) => bar.style.height + ); + + const initialHeights = getHeights(); + + // Advance timer and force re-render + vi.advanceTimersByTime(100); + rerender(); + + const updatedHeights = getHeights(); + + expect(initialHeights).to.deep.equal(updatedHeights); + }); +}); diff --git a/frontend/src/components/Histogram/Histogram.jsx b/frontend/src/components/Histogram/Histogram.tsx similarity index 73% rename from frontend/src/components/Histogram/Histogram.jsx rename to frontend/src/components/Histogram/Histogram.tsx index 020d8738d..407713c5b 100644 --- a/frontend/src/components/Histogram/Histogram.jsx +++ b/frontend/src/components/Histogram/Histogram.tsx @@ -1,7 +1,18 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import classNames from "classnames"; -// Histogram with random bar movement for decoration +interface HistogramProps { + bars?: number; + spacing?: number; + interval?: number; + running?: boolean; + marginLeft?: number; + marginTop?: number; + backgroundColor?: string; + borderRadius?: string; +} + +/** Histogram with random bar movement for decoration */ const Histogram = ({ bars = 7, spacing = 6, @@ -11,7 +22,7 @@ const Histogram = ({ marginTop = 0, backgroundColor = undefined, borderRadius = '0.15rem', -}) => { +}: HistogramProps) => { const [pulse, setPulse] = useState(true); useEffect(() => { @@ -40,7 +51,7 @@ const Histogram = ({ return (
{_bars}
diff --git a/frontend/src/components/MatchingPairs/MatchingPairs.tsx b/frontend/src/components/MatchingPairs/MatchingPairs.tsx index 5b8405196..7e37ade79 100644 --- a/frontend/src/components/MatchingPairs/MatchingPairs.tsx +++ b/frontend/src/components/MatchingPairs/MatchingPairs.tsx @@ -1,11 +1,11 @@ -import React, { useRef, useState } from "react"; +import { useRef, useState } from "react"; import classNames from "classnames"; import { scoreIntermediateResult } from "../../API"; import useBoundStore from "@/util/stores"; import PlayCard from "./PlayCard"; -import Section from "@/types/Section"; +import { Card } from "@/types/Section"; import Session from "@/types/Session"; import Participant from "@/types/Participant"; @@ -15,17 +15,6 @@ export const SCORE_FEEDBACK_DISPLAY = { HIDDEN: 'hidden', } -interface Card extends Section { - turned: boolean; - inactive: boolean; - matchClass: string; - seen: boolean; - noevents: boolean; - boardposition: number; - timestamp: number; - response_interval_ms: number | string; -} - interface MatchingPairsProps { playSection: (index: number) => void; sections: Card[]; @@ -39,8 +28,8 @@ interface MatchingPairsProps { const MatchingPairs = ({ playSection, - // technically these are Sections, but we're adding some extra properties to them in a hacky way, - // which should be fixed in the future + /** FIXME: technically these are Sections, but we're adding some extra properties to them in a hacky way, + // which should be fixed in the future */ sections, playerIndex, showAnimation, diff --git a/frontend/src/components/MatchingPairs/PlayCard.test.jsx b/frontend/src/components/MatchingPairs/PlayCard.test.tsx similarity index 88% rename from frontend/src/components/MatchingPairs/PlayCard.test.jsx rename to frontend/src/components/MatchingPairs/PlayCard.test.tsx index 46af6b8cc..1a05a70ec 100644 --- a/frontend/src/components/MatchingPairs/PlayCard.test.jsx +++ b/frontend/src/components/MatchingPairs/PlayCard.test.tsx @@ -1,5 +1,4 @@ -import React from "react"; -import { vi } from "vitest"; +import { vi, describe, it, expect } from "vitest"; import { render, fireEvent, screen } from "@testing-library/react"; import PlayCard from "./PlayCard"; @@ -31,77 +30,77 @@ describe("PlayCard Component Tests", () => { it("should display the back of the card by default", () => { render(); - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".back"))).to.be.true; + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".back"))).toBe(true); }); it("should display the front of the card when turned", () => { render(); - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).to.be.true; - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".front"))).to.not.be.true; + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).toBe(true); + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".front"))).not.toBe(true); }); it("should display image for visual matching pairs view", () => { render(); - expect(document.body.contains(screen.getByAltText("Test"))).to.be.true; + expect(document.body.contains(screen.getByAltText("Test"))).toBe(true); }); it("should display histogram for non-visual matching pairs view", () => { render(); - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).to.be.true; + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).toBe(true); }); it("should display a disabled card when inactive", () => { render(); - expect(screen.getByTestId("play-card").classList.contains("disabled")).to.be.true; + expect(screen.getByTestId("play-card").classList.contains("disabled")).toBe(true); }); it("should display a card with no events when noevents", () => { render(); - expect(screen.getByTestId("play-card").classList.contains("noevents")).to.be.true; + expect(screen.getByTestId("play-card").classList.contains("noevents")).toBe(true); }); it("should display a card with fbmemory when memory", () => { render(); - expect(screen.getByRole("button").classList.contains("fbmemory")).to.be.true; + expect(screen.getByRole("button").classList.contains("fbmemory")).toBe(true); }); it("should display a card with fblucky when lucky", () => { render(); - expect(screen.getByRole("button").classList.contains("fblucky")).to.be.true; + expect(screen.getByRole("button").classList.contains("fblucky")).toBe(true); }); it("should display a card with fbnomatch when nomatch", () => { render(); - expect(screen.getByRole("button").classList.contains("fbnomatch")).to.be.true; + expect(screen.getByRole("button").classList.contains("fbnomatch")).toBe(true); }); it("should not apply matchClass when showAnimations is false", () => { render(); - expect(screen.getByRole("button").classList.contains("fbnomatch")).not.to.be.true; + expect(screen.getByRole("button").classList.contains("fbnomatch")).not.toBe(true); }) it("should display a card with seen when seen", () => { render(); - expect(screen.getByTestId("play-card").querySelector(".back").classList.contains("seen")).to.be.true; + expect(screen.getByTestId("play-card").querySelector(".back").classList.contains("seen")).toBe(true); }); it("should display a card with a histogram when turned and playing", () => { render(); - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).to.be.true; + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).toBe(true); }); it("should display a card with a histogram when turned and not playing", () => { render(); - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).to.be.true; + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).toBe(true); }); it("should display a card without a histogram when not turned and playing", () => { render(); - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).to.not.be.true; + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).not.toBe(true); }); it("should display a card without a histogram when not turned and not playing", () => { render(); - expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).to.not.be.true; + expect(document.body.contains(screen.getByTestId("play-card").querySelector(".aha__histogram"))).not.toBe(true); }); }); diff --git a/frontend/src/components/MatchingPairs/PlayCard.jsx b/frontend/src/components/MatchingPairs/PlayCard.tsx similarity index 81% rename from frontend/src/components/MatchingPairs/PlayCard.jsx rename to frontend/src/components/MatchingPairs/PlayCard.tsx index 115fa7dd1..6f246c4f4 100644 --- a/frontend/src/components/MatchingPairs/PlayCard.jsx +++ b/frontend/src/components/MatchingPairs/PlayCard.tsx @@ -2,15 +2,25 @@ import classNames from "classnames"; import Histogram from "../Histogram/Histogram"; import { API_ROOT } from "@/config"; +import { Card } from "@/types/Section"; -const PlayCard = ({ onClick, registerUserClicks, playing, section, view, showAnimation }) => { - const getImgSrc = (url) => { +interface PlayCardProps { + onClick: () => void; + registerUserClicks: (x: number, y: number) => void; + playing: boolean; + section: Card; + view: string; + showAnimation: boolean; +} + +const PlayCard = ({ onClick, registerUserClicks, playing, section, view, showAnimation }: PlayCardProps) => { + const getImgSrc = (url: string) => { if (url.startsWith("http")) { return url; } return API_ROOT + url; } - const matchClass = showAnimation? section.matchClass : ''; + const matchClass = showAnimation ? section.matchClass : ''; const histogramBars = showAnimation ? 5 : 0; @@ -44,7 +54,6 @@ const PlayCard = ({ onClick, registerUserClicks, playing, section, view, showAni diff --git a/frontend/src/components/PlayButton/PlayerSmall.jsx b/frontend/src/components/PlayButton/PlayerSmall.jsx index 2c23e84d1..edcd9b269 100644 --- a/frontend/src/components/PlayButton/PlayerSmall.jsx +++ b/frontend/src/components/PlayButton/PlayerSmall.jsx @@ -2,8 +2,8 @@ import React from "react"; import PlayButton from "./PlayButton"; import classNames from "classnames"; -const PlayerSmall = ({ label, onClick, playing, disabled}) => ( -
+const PlayerSmall = ({ label, onClick, playing, disabled }) => ( +
{label && <>

{label}

diff --git a/frontend/src/components/Playback/ImagePlayer.test.tsx b/frontend/src/components/Playback/ImagePlayer.test.tsx new file mode 100644 index 000000000..0513c8cd6 --- /dev/null +++ b/frontend/src/components/Playback/ImagePlayer.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import ImagePlayer from './ImagePlayer'; +import Section from '@/types/Section'; + +describe('ImagePlayer Component', () => { + const mockPlaySection = vi.fn(); + const mockImages = ['image1.jpg', 'image2.jpg']; + const mockLabels = ['Label 1', 'Label 2']; + + const defaultProps = { + images: mockImages, + playSection: mockPlaySection, + sections: [{ id: 123, url: '123451' }, { id: 124, url: '123452' }] as Section[] + }; + + it('renders MultiPlayer with correct props', () => { + render(); + const multiPlayer = screen.getByTestId('multiplayer'); + expect(multiPlayer).toBeTruthy(); + }); + + it('renders images correctly', () => { + render(); + const images = screen.getAllByAltText('PlayerImage'); + expect(images.length).toBe(mockImages.length); + expect(images[0].getAttribute('src')).toBe(mockImages[0]); + expect(images[1].getAttribute('src')).toBe(mockImages[1]); + }); + + it('calls playSection when image is clicked', () => { + render(); + const images = screen.getAllByAltText('PlayerImage'); + fireEvent.click(images[0]); + expect(mockPlaySection).toHaveBeenCalledWith(0); + }); + + it('renders labels when provided', () => { + render(); + const labels = screen.getAllByText(/Label \d/); + expect(labels.length).toBe(mockLabels.length); + expect(labels[0].textContent).toBe(mockLabels[0]); + expect(labels[1].textContent).toBe(mockLabels[1]); + }); + + it('displays warning when no images are provided', () => { + render(); + const warning = screen.getAllByText('Warning: No images found'); + expect(warning.length).toBe(2); + }); + + it('displays warning for out-of-range index', () => { + + const props = { + ...defaultProps, + images: ['image1.jpg',], + } + + render(); + const warning = screen.getByText('Warning: No spectrograms available for index 1'); + expect(warning).toBeTruthy(); + }); + + it('applies correct class to root element', () => { + const { container } = render(); + expect(container.firstChild.classList.contains('aha__image-player')).toBe(true); + }); +}); diff --git a/frontend/src/components/Playback/ImagePlayer.jsx b/frontend/src/components/Playback/ImagePlayer.tsx similarity index 78% rename from frontend/src/components/Playback/ImagePlayer.jsx rename to frontend/src/components/Playback/ImagePlayer.tsx index fe895fbe8..c233e4f38 100644 --- a/frontend/src/components/Playback/ImagePlayer.jsx +++ b/frontend/src/components/Playback/ImagePlayer.tsx @@ -1,14 +1,21 @@ -import React, { useCallback } from "react"; +import { useCallback } from "react"; import MultiPlayer from "./MultiPlayer"; -const ImagePlayer = (props) => { +interface ImagePlayerProps { + images: string[]; + // FIXME: image_labels is never passed as a prop by any parent component + image_labels?: string[]; + playSection: (index: number) => void; +} + +const ImagePlayer = (props: ImagePlayerProps) => { const playSection = props.playSection; // extraContent callback can be used to add content to each player const extraContent = useCallback( - (index) => { + (index: number) => { const images = props.images; - if (!images) { + if (!images || images.length === 0) { return

Warning: No images found

; } const labels = props.image_labels; diff --git a/frontend/src/components/Playback/MultiPlayer.test.tsx b/frontend/src/components/Playback/MultiPlayer.test.tsx new file mode 100644 index 000000000..acd3b39c0 --- /dev/null +++ b/frontend/src/components/Playback/MultiPlayer.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import MultiPlayer from './MultiPlayer'; + +describe('MultiPlayer Component', () => { + const mockPlaySection = vi.fn(); + const mockSections = [ + { id: '1', url: '123451' }, + { id: '2', url: '123452' }, + { id: '3', url: '123453' }, + ]; + + const defaultProps = { + playSection: mockPlaySection, + sections: mockSections, + playerIndex: '0', + labels: { '0': 'Label 1', '1': 'Label 2', '2': 'Label 3' }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders correct number of PlayerSmall components', () => { + render(); + const players = screen.getAllByRole('button'); + expect(players.length).toBe(mockSections.length); + }); + + test('applies correct class names', () => { + const { container } = render(); + + if (!container.firstChild) { + throw new Error('No container.firstChild'); + } + + const classList = container.firstChild.classList; + + expect(classList.contains('aha__multiplayer')).toBe(true); + expect(classList.contains('d-flex')).toBe(true); + expect(classList.contains('justify-content-around')).toBe(true); + expect(classList.contains('player-count-3')).toBe(true); + }); + + test('calls playSection with correct index when PlayerSmall is clicked', () => { + render(); + const players = screen.getAllByRole('button'); + fireEvent.click(players[1]); + expect(mockPlaySection).toHaveBeenCalledWith(1); + }); + + test('applies correct label to PlayerSmall components', () => { + render(); + expect(screen.getByText('Label 1')).toBeTruthy(); + expect(screen.getByText('Label 2')).toBeTruthy(); + expect(screen.getByText('Label 3')).toBeTruthy(); + }); + + test('renders extraContent when provided', () => { + const extraContent = (index: string) => Extra {index}; + render(); + expect(screen.getByTestId('extra-0')).toBeTruthy(); + expect(screen.getByTestId('extra-1')).toBeTruthy(); + expect(screen.getByTestId('extra-2')).toBeTruthy(); + }); + + test('applies custom styles when provided', () => { + const customStyle = { root: 'custom-root-class' }; + const { container } = render(); + expect(container.firstChild.classList.contains('custom-root-class')).toBe(true); + }); +}); diff --git a/frontend/src/components/Playback/MultiPlayer.jsx b/frontend/src/components/Playback/MultiPlayer.tsx similarity index 61% rename from frontend/src/components/Playback/MultiPlayer.jsx rename to frontend/src/components/Playback/MultiPlayer.tsx index 66b80144e..fa503c22f 100644 --- a/frontend/src/components/Playback/MultiPlayer.jsx +++ b/frontend/src/components/Playback/MultiPlayer.tsx @@ -1,6 +1,18 @@ import React from "react"; import PlayerSmall from "../PlayButton/PlayerSmall"; import classNames from "classnames"; +import { PlaybackArgs } from "@/types/Playback"; +import Section from "@/types/Section"; + +interface MultiPlayerProps { + playSection: (index: number) => void; + sections: Section[]; + playerIndex: string; + labels?: PlaybackArgs["labels"]; + disabledPlayers?: number[]; + extraContent?: (index: number) => JSX.Element; + style?: PlaybackArgs["style"]; +} const MultiPlayer = ({ playSection, @@ -10,16 +22,17 @@ const MultiPlayer = ({ disabledPlayers, extraContent, style, -}) => { +}: MultiPlayerProps) => { return (
- {Object.keys(sections).map((index) => ( + {sections.map((_section, index) => (
{ @@ -27,12 +40,12 @@ const MultiPlayer = ({ }} disabled={ Array.isArray(disabledPlayers) && - disabledPlayers.includes(parseInt(index)) + disabledPlayers.includes(index) } label={ - labels? labels[index] : "" + labels ? labels[index] : "" } - playing={playerIndex === index} + playing={parseInt(playerIndex) === index} /> {extraContent && extraContent(index)}
) diff --git a/frontend/src/components/Playback/Playback.test.jsx b/frontend/src/components/Playback/Playback.test.tsx similarity index 65% rename from frontend/src/components/Playback/Playback.test.jsx rename to frontend/src/components/Playback/Playback.test.tsx index 75eee50d9..d3bb8ee53 100644 --- a/frontend/src/components/Playback/Playback.test.jsx +++ b/frontend/src/components/Playback/Playback.test.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { render } from '@testing-library/react'; import Playback from './Playback'; +import { PlaybackArgs } from '@/types/Playback'; vi.mock("../../util/stores"); @@ -17,21 +17,22 @@ describe('Playback', () => { submitResult: vi.fn(), } - let playbackArgs = { + let playbackArgs: PlaybackArgs = { view: 'BUTTON', show_animation: false, instruction: 'Listen, just listen!', play_method: 'HTML', preload_message: 'Get ready', - sections: [{id: 13, url: 'some/fancy/tune.mp3'}] + sections: [{ id: 13, url: 'some/fancy/tune.mp3' }], + play_from: 0, }; it('renders itself', () => { const { container } = render( - ); - expect(document.body.contains(container.querySelector('.aha__playback'))).to.be.true; + ); + expect(document.body.contains(container.querySelector('.aha__playback'))).toBe(true); }); -}) \ No newline at end of file +}) diff --git a/frontend/src/components/Playback/Playback.jsx b/frontend/src/components/Playback/Playback.tsx similarity index 79% rename from frontend/src/components/Playback/Playback.jsx rename to frontend/src/components/Playback/Playback.tsx index e81a9ffe9..277020cd9 100644 --- a/frontend/src/components/Playback/Playback.jsx +++ b/frontend/src/components/Playback/Playback.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import * as audio from "../../util/audio"; import * as webAudio from "../../util/webAudio"; @@ -10,13 +10,21 @@ import MultiPlayer from "./MultiPlayer"; import ImagePlayer from "./ImagePlayer"; import MatchingPairs from "../MatchingPairs/MatchingPairs"; import Preload from "../Preload/Preload"; +import { AUTOPLAY, BUTTON, IMAGE, MATCHINGPAIRS, MULTIPLAYER, PRELOAD, PlaybackArgs, PlaybackView } from "@/types/Playback"; -export const AUTOPLAY = "AUTOPLAY"; -export const BUTTON = "BUTTON"; -export const MULTIPLAYER = "MULTIPLAYER"; -export const IMAGE = "IMAGE"; -export const MATCHINGPAIRS = "MATCHINGPAIRS"; -export const PRELOAD = "PRELOAD"; +interface PlaybackProps { + playbackArgs: PlaybackArgs; + onPreloadReady: () => void; + autoAdvance: boolean; + responseTime: number; + submitResult: (result: any) => void; + startedPlaying?: () => void; + finishedPlaying: () => void; +} + +interface PlaybackState { + view: PlaybackView; +} const Playback = ({ playbackArgs, @@ -26,24 +34,24 @@ const Playback = ({ submitResult, startedPlaying, finishedPlaying, -}) => { +}: PlaybackProps) => { const [playerIndex, setPlayerIndex] = useState(-1); const lastPlayerIndex = useRef(-1); - const activeAudioEndedListener = useRef(null); - const [state, setState] = useState({ view: PRELOAD }); - const setView = (view, data = {}) => { + const activeAudioEndedListener = useRef<() => void>(); + const [state, setState] = useState({ view: PRELOAD }); + const setView = (view: PlaybackView, data = {}) => { setState({ view, ...data }); } - + // check if the users device is webaudio compatible - const playMethod = webAudio.compatibleDevice() ? playbackArgs.play_method : 'EXTERNAL'; - + const playMethod = webAudio.compatibleDevice() ? playbackArgs.play_method : 'EXTERNAL'; + const { sections, style } = playbackArgs; // Keep track of which player has played, in a an array of player indices - const [hasPlayed, setHasPlayed] = useState([]); + const [hasPlayed, setHasPlayed] = useState([]); const prevPlayerIndex = useRef(-1); - + useEffect(() => { const index = prevPlayerIndex.current; if (index !== -1) { @@ -51,12 +59,13 @@ const Playback = ({ index === -1 || hasPlayed.includes(index) ? hasPlayed : [...hasPlayed, index] ); } - prevPlayerIndex.current = parseInt(playerIndex); + prevPlayerIndex.current = parseInt(playerIndex, 10); }, [playerIndex]); // Cancel events const cancelAudioListeners = useCallback(() => { - activeAudioEndedListener.current && activeAudioEndedListener.current(); + activeAudioEndedListener.current + && activeAudioEndedListener.current(); }, []); // Cancel all events when component unmounts @@ -67,8 +76,8 @@ const Playback = ({ }, [cancelAudioListeners]); // Audio ended playing - const onAudioEnded = useCallback((index) => { - + const onAudioEnded = useCallback((index: number) => { + // If the player index is not the last player index, return if (lastPlayerIndex.current === index) { setPlayerIndex(-1); @@ -80,22 +89,22 @@ const Playback = ({ finishedPlaying(); } }, [playbackArgs, finishedPlaying]); - + // Keep track of last player index useEffect(() => { lastPlayerIndex.current = playerIndex; }, [playerIndex]); - if (playMethod === 'EXTERNAL') { - webAudio.closeWebAudio(); + if (playMethod === 'EXTERNAL') { + webAudio.closeWebAudio(); } const getPlayheadShift = useCallback(() => { /* if the current Playback view has resume_play set to true, retrieve previous Playback view's decisionTime from sessionStorage */ - return playbackArgs.resume_play ? - parseFloat(window.sessionStorage.getItem('decisionTime')) : 0; + return playbackArgs.resume_play ? + parseFloat(window.sessionStorage.getItem('decisionTime')) : 0; }, [playbackArgs] ) @@ -108,35 +117,43 @@ const Playback = ({ // Load different audio if (prevPlayerIndex.current !== -1) { pauseAudio(playMethod); - } + } + // Store player index setPlayerIndex(index); + // Determine if audio should be played if (playbackArgs.mute) { setPlayerIndex(-1); pauseAudio(playMethod); return; } - + const playheadShift = getPlayheadShift(); let latency = playAudio(sections[index], playMethod, playheadShift + playbackArgs.play_from); + // Cancel active events cancelAudioListeners(); + // listen for active audio events if (playMethod === 'BUFFER') { activeAudioEndedListener.current = webAudio.listenOnce("ended", () => onAudioEnded(index)); } else { activeAudioEndedListener.current = audio.listenOnce("ended", () => onAudioEnded(index)); - } + } + // Compensate for audio latency and set state to playing - setTimeout(startedPlaying && startedPlaying(), latency); + if (startedPlaying) { + setTimeout(startedPlaying, latency); + } + return; } // Stop playback if (lastPlayerIndex.current === index) { - pauseAudio(playMethod); - setPlayerIndex(-1); - return; + pauseAudio(playMethod); + setPlayerIndex(-1); + return; } }, [sections, activeAudioEndedListener, cancelAudioListeners, getPlayheadShift, playbackArgs, playMethod, startedPlaying, onAudioEnded] ); @@ -156,7 +173,7 @@ const Playback = ({ [playMethod] ); - const render = (view) => { + const render = (view: PlaybackView) => { const attrs = { autoAdvance, finishedPlaying: onFinishedPlaying, @@ -181,15 +198,15 @@ const Playback = ({ { + onNext={() => { setView(playbackArgs.view); onPreloadReady(); }} /> - ); + ); case AUTOPLAY: return ; case BUTTON: return ( @@ -219,10 +236,11 @@ const Playback = ({ return ( ); default: diff --git a/frontend/src/components/Preload/Preload.jsx b/frontend/src/components/Preload/Preload.tsx similarity index 69% rename from frontend/src/components/Preload/Preload.jsx rename to frontend/src/components/Preload/Preload.tsx index 5cb315956..bd0e3361a 100644 --- a/frontend/src/components/Preload/Preload.jsx +++ b/frontend/src/components/Preload/Preload.tsx @@ -1,17 +1,27 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import classNames from "classnames"; import ListenFeedback from "../Listen/ListenFeedback"; import CountDown from "../CountDown/CountDown"; import * as audio from "../../util/audio"; import * as webAudio from "../../util/webAudio"; +import Section from "@/types/Section"; -// Preload is an experiment screen that continues after a given time or after an audio file has been preloaded -const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, onNext }) => { +interface PreloadProps { + sections: Section[]; + playMethod: string; + duration: number; + preloadMessage: string; + pageTitle: string; + onNext: () => void; +} + +/** Preload is an experiment screen that continues after a given time or after an audio file has been preloaded */ +const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, onNext }: PreloadProps) => { const [audioAvailable, setAudioAvailable] = useState(false); const [overtime, setOvertime] = useState(false); const [loaderDuration, setLoaderDuration] = useState(duration); - + const onTimePassed = () => { setLoaderDuration(0); setOvertime(true); @@ -31,47 +41,50 @@ const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, on } if (playMethod === 'BUFFER') { - // Use Web-audio and preload sections in buffers + + // Use Web-audio and preload sections in buffers + sections.forEach((section, index) => { + // skip Preload if the section has already been loaded in the previous action if (webAudio.checkSectionLoaded(section)) { - if (index === (sections.length - 1)) { - setAudioAvailable(true); + if (index === (sections.length - 1)) { + setAudioAvailable(true); } return; } - + // Clear buffers if this is the first section if (index === 0) { webAudio.clearBuffers(); } - // Load sections in buffer - return webAudio.loadBuffer(section.id, section.url, () => { - if (index === (sections.length - 1)) { - setAudioAvailable(true); - } + // Load sections in buffer + return webAudio.loadBuffer(section.id, section.url, () => { + if (index === (sections.length - 1)) { + setAudioAvailable(true); + } }); }) } else { - if (playMethod === 'EXTERNAL') { - webAudio.closeWebAudio(); + if (playMethod === 'EXTERNAL') { + webAudio.closeWebAudio(); } // Load audio until available // Return remove listener sections.forEach((section, index) => { return audio.loadUntilAvailable(section.url, () => { - if (index === (sections.length - 1)) { - setAudioAvailable(true); + if (index === (sections.length - 1)) { + setAudioAvailable(true); } }); - }) + }) } } preloadResources(); }, [sections, playMethod, onNext]); - + return ( { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: () => mockUseLocation() + }; +}); + +describe('Reload', () => { + + // Mock window.location + const originalLocation = window.location; + + beforeEach(() => { + vi.resetAllMocks(); + + mockUseLocation.mockReturnValue({ pathname: '/' }); + + // Mock window.location + delete window.location; + window.location = { ...originalLocation, href: '' }; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it('renders without crashing', () => { + mockUseLocation.mockReturnValue({ pathname: '/' }); + const { container } = render( + + + + ); + expect(container.querySelector('.aha__reload')).toBeTruthy(); + }); + + it('redirects to the correct URL', () => { + const testPath = '/test-path'; + mockUseLocation.mockReturnValue({ pathname: testPath }); + + render( + + + + ); + + expect(window.location.href).toBe(API_BASE_URL + testPath); + }); + + it('uses the current location for redirection', () => { + const testPath = '/another-path'; + mockUseLocation.mockReturnValue({ pathname: testPath }); + + render( + + + + ); + + expect(window.location.href).toBe(API_BASE_URL + testPath); + }); +}); diff --git a/frontend/src/components/Reload/Reload.jsx b/frontend/src/components/Reload/Reload.tsx similarity index 77% rename from frontend/src/components/Reload/Reload.jsx rename to frontend/src/components/Reload/Reload.tsx index 21792123c..ffee7cf70 100644 --- a/frontend/src/components/Reload/Reload.jsx +++ b/frontend/src/components/Reload/Reload.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { useLocation } from 'react-router-dom'; import { API_BASE_URL } from "@/config"; @@ -6,7 +6,7 @@ import { API_BASE_URL } from "@/config"; const Reload = () => { const location = useLocation(); - useEffect( () => { + useEffect(() => { window.location.href = API_BASE_URL + location.pathname; }); @@ -16,4 +16,4 @@ const Reload = () => { ) } -export default Reload; \ No newline at end of file +export default Reload; diff --git a/frontend/src/components/Score/Score.test.jsx b/frontend/src/components/Score/Score.test.jsx index cba321e70..09859c0b9 100644 --- a/frontend/src/components/Score/Score.test.jsx +++ b/frontend/src/components/Score/Score.test.jsx @@ -22,8 +22,8 @@ describe('Score component', () => { }; render(); - expect(document.body.contains(screen.getByText('Great job!'))).to.be.true; - expect(document.body.contains(screen.getByText('Test Song'))).to.be.true; + expect(document.body.contains(screen.getByText('Great job!'))).toBe(true); + expect(document.body.contains(screen.getByText('Test Song'))).toBe(true); }); diff --git a/frontend/src/components/Trial/Trial.tsx b/frontend/src/components/Trial/Trial.tsx index 853e76d17..9d07551cd 100644 --- a/frontend/src/components/Trial/Trial.tsx +++ b/frontend/src/components/Trial/Trial.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback } from "react"; import classNames from "classnames"; import { getCurrentTime, getTimeSince } from "../../util/time"; @@ -6,6 +6,26 @@ import FeedbackForm from "../FeedbackForm/FeedbackForm"; import HTML from "../HTML/HTML"; import Playback from "../Playback/Playback"; import Button from "../Button/Button"; +import Question from "@/types/Question"; +import { OnResultType } from "@/hooks/useResultHandler"; +import { TrialConfig } from "@/types/Trial"; + +interface IFeedbackForm { + form: Question[]; + submit_label: string; + skip_label: string; + is_skippable: boolean; +} + +interface TrialProps { + playback: any; + html: { body: string | TrustedHTML }; + feedback_form: IFeedbackForm; + config: TrialConfig; + result_id: string; + onNext: (breakRound?: boolean) => void; + onResult: OnResultType; +} /** * Trial is an block view to present information to the user and/or collect user feedback @@ -13,7 +33,7 @@ import Button from "../Button/Button"; * If "html" is provided, it will show html content * If "feedback_form" is provided, it will present a form of questions to the user */ -const Trial = (props) => { +const Trial = (props: TrialProps) => { const { playback, @@ -41,13 +61,15 @@ const Trial = (props) => { // Create result data const makeResult = useCallback( - async (result) => { + async (result: { type: 'time_passed' }) => { // Prevent multiple submissions if (submitted.current) { return; } submitted.current = true; + // TODO: Check if we can find another solution for + // the default value of form than [{}] const form = feedback_form ? feedback_form.form : [{}]; if (result.type === "time_passed") { @@ -97,7 +119,8 @@ const Trial = (props) => { const checkBreakRound = (values, breakConditions) => { switch (Object.keys(breakConditions)[0]) { case 'EQUALS': - return values.some(val => breakConditions['EQUALS'].includes(val)); + return values.some(val => breakConditions['EQUALS'] + .includes(val)); case 'NOT': return !values.some(val => breakConditions['NOT'].includes(val)); default: diff --git a/frontend/src/hooks/useResultHandler.ts b/frontend/src/hooks/useResultHandler.ts index 17e0f5dbd..b4c16aff9 100644 --- a/frontend/src/hooks/useResultHandler.ts +++ b/frontend/src/hooks/useResultHandler.ts @@ -2,6 +2,8 @@ import { useRef, useCallback } from "react"; import { scoreResult } from "@/API"; import Session from "@/types/Session"; import Participant from "@/types/Participant"; +import Question from "@/types/Question"; +import { TrialConfig } from "@/types/Trial"; interface ResultData { session: Session; @@ -10,18 +12,31 @@ interface ResultData { section?: unknown; } +interface UseResultHandlerParams { + session: Session; + participant: Participant; + onNext: () => void; + state: any; +} + +interface OnResultParams { + form: Question[]; + decision_time?: number; + config?: TrialConfig +} + /** * useResult provides a reusable function to handle block view data * - collect results in a buffer * - handles advancing to next round * - finally submits the data to the API and loads the new state */ -const useResultHandler = ({ session, participant, onNext, state }) => { +const useResultHandler = ({ session, participant, onNext, state }: UseResultHandlerParams) => { const resultBuffer = useRef([]); const onResult = useCallback( async ( - result: unknown, + result: OnResultParams, forceSubmit = false, goToNextAction = true ) => { @@ -74,4 +89,6 @@ const useResultHandler = ({ session, participant, onNext, state }) => { return onResult; }; +export type OnResultType = ReturnType; + export default useResultHandler; diff --git a/frontend/src/types/Playback.ts b/frontend/src/types/Playback.ts new file mode 100644 index 000000000..c663e242e --- /dev/null +++ b/frontend/src/types/Playback.ts @@ -0,0 +1,39 @@ +import Section from "./Section"; + +export const AUTOPLAY = "AUTOPLAY"; +export const BUTTON = "BUTTON"; +export const MULTIPLAYER = "MULTIPLAYER"; +export const IMAGE = "IMAGE"; +export const MATCHINGPAIRS = "MATCHINGPAIRS"; +export const PRELOAD = "PRELOAD"; + +export type PlaybackView = typeof AUTOPLAY | typeof BUTTON | typeof MULTIPLAYER | typeof IMAGE | typeof MATCHINGPAIRS | typeof PRELOAD; + +type PlaybackMethod = "EXTERNAL" | "HTML" | "BUFFER" | "NOAUDIO"; + +interface FrontendStyle { + root: string | FrontendStyle; + [key: string]: string | FrontendStyle; +} + +type Labels = { [key: string]: string }; + +export interface PlaybackArgs { + view: PlaybackView; + play_method: PlaybackMethod; + show_animation: boolean; + preload_message: string; + instruction: string; + sections: Section[]; + play_from: number; + + labels?: Labels; + style?: FrontendStyle; + images?: { [key: string]: string }; + mute?: boolean; + play_once?: boolean; + resume_play?: boolean; + stop_audio_after?: number; + timeout_after_playback?: number; + score_feedback_display?: string; +} diff --git a/frontend/src/types/Section.ts b/frontend/src/types/Section.ts index fe1522c6b..1c61bfd5e 100644 --- a/frontend/src/types/Section.ts +++ b/frontend/src/types/Section.ts @@ -1,4 +1,18 @@ -export default interface Section { +export interface Section { id: number; url: string; } + +export interface Card extends Section { + name: string; + turned: boolean; + inactive: boolean; + matchClass: string; + seen: boolean; + noevents: boolean; + boardposition: number; + timestamp: number; + response_interval_ms: number | string; +} + +export default Section; diff --git a/frontend/src/types/Trial.ts b/frontend/src/types/Trial.ts new file mode 100644 index 000000000..f0e58c87b --- /dev/null +++ b/frontend/src/types/Trial.ts @@ -0,0 +1,11 @@ +export interface TrialConfig { + response_time: number; + auto_advance: boolean; + listen_first: boolean; + show_continue_button: boolean; + continue_label: string; + style: string; + break_round_on: any; + + auto_advance_timer: number | null; +} diff --git a/frontend/src/util/audio.ts b/frontend/src/util/audio.ts index 3358828ac..a68d7a62d 100644 --- a/frontend/src/util/audio.ts +++ b/frontend/src/util/audio.ts @@ -6,7 +6,7 @@ import Timer from "./timer"; //