diff --git a/frontend/src/components/Histogram/Histogram.test.tsx b/frontend/src/components/Histogram/Histogram.test.tsx index 81c99dc42..7ccd40456 100644 --- a/frontend/src/components/Histogram/Histogram.test.tsx +++ b/frontend/src/components/Histogram/Histogram.test.tsx @@ -3,24 +3,23 @@ import { render, act, waitFor } from '@testing-library/react'; import Histogram from './Histogram'; // Mock requestAnimationFrame and cancelAnimationFrame -vi.mock('global', () => ({ - requestAnimationFrame: (callback: FrameRequestCallback): number => { - return setTimeout(callback, 0); - }, - cancelAnimationFrame: (handle: number): void => { - clearTimeout(handle); - } -})); +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => { + return setTimeout(callback, 16); // Approximate 60 FPS +}); -describe('Histogram', () => { +vi.stubGlobal('cancelAnimationFrame', (handle: number): void => { + clearTimeout(handle); +}); +// Mock setInterval and clearInterval +vi.useFakeTimers(); + +describe('Histogram', () => { let mockAnalyser: { getByteFrequencyData: vi.Mock; }; beforeEach(() => { - vi.useFakeTimers({ toFake: ['requestAnimationFrame'] }); - // Mock the Web Audio API mockAnalyser = { getByteFrequencyData: vi.fn(), @@ -31,7 +30,7 @@ describe('Histogram', () => { }); afterEach(() => { - vi.useRealTimers(); + vi.clearAllTimers(); vi.restoreAllMocks(); }); @@ -126,32 +125,129 @@ describe('Histogram', () => { expect(mockAnalyser.getByteFrequencyData).toHaveBeenCalled(); }); - it('does not update bar heights when not running', async () => { + it('does not update bar heights when not running', () => { const bars = 5; - mockAnalyser.getByteFrequencyData.mockImplementation((array) => { - for (let i = 0; i < array.length; i++) { - array[i] = Math.floor(Math.random() * 256); - } + mockAnalyser.getByteFrequencyData.mockImplementation(() => { + // This should not be called when running is false }); - const { container, rerender } = render(); + const { container } = render(); - const getHeights = () => Array.from(container.querySelectorAll('.aha__histogram > div')).map( - (bar) => bar.style.height + const getHeights = () => + Array.from(container.querySelectorAll('.aha__histogram > div')).map( + (bar) => bar.style.height + ); + + const initialHeights = getHeights(); + + // Advance timers to simulate time passing + act(() => { + vi.advanceTimersByTime(1000); // Advance time by 1 second + }); + + const updatedHeights = getHeights(); + + expect(initialHeights).to.deep.equal(updatedHeights); + expect(mockAnalyser.getByteFrequencyData).not.toHaveBeenCalled(); + }); + + it('updates bar heights based on random data when random is true and running is true', async () => { + const bars = 5; + + // Ensure the analyser does not provide data + mockAnalyser.getByteFrequencyData.mockImplementation(() => { }); + + const { container, rerender } = render( + ); + const getHeights = () => + Array.from(container.querySelectorAll('.aha__histogram > div')).map( + (bar) => bar.style.height + ); + const initialHeights = getHeights(); // Advance timers and trigger animation frame - await waitFor(async () => { - vi.advanceTimersToNextFrame(); + await act(async () => { + vi.advanceTimersByTime(100); }); - rerender(); + rerender(); const updatedHeights = getHeights(); - expect(initialHeights).to.deep.equal(updatedHeights); + expect(initialHeights).not.to.deep.equal(updatedHeights); + expect(mockAnalyser.getByteFrequencyData).not.toHaveBeenCalled(); + }); + + it('does not call getByteFrequencyData when random is true', async () => { + const bars = 5; + + const { rerender } = render( + + ); + + // Advance timers and trigger animation frame + await act(async () => { + vi.advanceTimersByTime(100); + }); + + rerender(); + expect(mockAnalyser.getByteFrequencyData).not.toHaveBeenCalled(); }); + + it('updates bar heights based on random data at the specified interval', async () => { + const bars = 5; + const interval = 200; + + const { container } = render( + + ); + + const getHeights = () => + Array.from(container.querySelectorAll('.aha__histogram > div')).map( + (bar) => bar.style.height + ); + + const initialHeights = getHeights(); + + // Advance timers by the interval to trigger the update + await act(async () => { + vi.advanceTimersByTime(interval); + }); + + const updatedHeights = getHeights(); + + expect(initialHeights).not.to.deep.equal(updatedHeights); + }); + + it('updates bar heights based on frequency data using requestAnimationFrame', async () => { + const bars = 5; + mockAnalyser.getByteFrequencyData.mockImplementation((array) => { + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + }); + + const { container } = render(); + + const getHeights = () => + Array.from(container.querySelectorAll('.aha__histogram > div')).map( + (bar) => bar.style.height + ); + + const initialHeights = getHeights(); + + // Advance timers to simulate requestAnimationFrame calls + await act(async () => { + vi.advanceTimersByTime(16); // Approximate time for one frame at 60 FPS + }); + + const updatedHeights = getHeights(); + + expect(initialHeights).not.to.deep.equal(updatedHeights); + expect(mockAnalyser.getByteFrequencyData).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/Histogram/Histogram.tsx b/frontend/src/components/Histogram/Histogram.tsx index 447b1f729..e3055199a 100644 --- a/frontend/src/components/Histogram/Histogram.tsx +++ b/frontend/src/components/Histogram/Histogram.tsx @@ -9,6 +9,13 @@ interface HistogramProps { marginTop?: number; backgroundColor?: string; borderRadius?: string; + random?: boolean; + /** + * If `random` is `true`, this prop sets the update interval in milliseconds. + * Default is 100 ms. + * Ignored when `random` is `false`. + */ + interval?: number; } const Histogram: React.FC = ({ @@ -19,40 +26,74 @@ const Histogram: React.FC = ({ marginTop = 0, backgroundColor = undefined, borderRadius = '0.15rem', + random = false, + interval = 100, }) => { const [frequencyData, setFrequencyData] = useState(new Uint8Array(bars)); - const requestRef = useRef(); + const animationFrameRef = useRef(); + const intervalRef = useRef(); useEffect(() => { if (!running) { - if (requestRef.current) { - const emptyHistogram = new Uint8Array(bars); - setFrequencyData(emptyHistogram); - cancelAnimationFrame(requestRef.current); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); } + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + const emptyHistogram = new Uint8Array(bars); + setFrequencyData(emptyHistogram); return; } const updateFrequencyData = () => { - if (window.audioContext && window.analyzer) { + let dataWithoutExtremes: Uint8Array; + + if (random) { + // Generate random frequency data + dataWithoutExtremes = new Uint8Array(bars); + for (let i = 0; i < bars; i++) { + dataWithoutExtremes[i] = Math.floor(Math.random() * 256); + } + setFrequencyData(dataWithoutExtremes); + } else if (window.audioContext && window.analyzer) { const data = new Uint8Array(bars + 3); window.analyzer.getByteFrequencyData(data); // Remove the lower end of the frequency data - const dataWithoutExtremes = data.slice(3, bars + 3); + dataWithoutExtremes = data.slice(3, bars + 3); + setFrequencyData(dataWithoutExtremes); + animationFrameRef.current = requestAnimationFrame(updateFrequencyData); + return; // Exit the function to prevent setting another interval + } else { + dataWithoutExtremes = new Uint8Array(bars); setFrequencyData(dataWithoutExtremes); } - requestRef.current = requestAnimationFrame(updateFrequencyData); }; - requestRef.current = requestAnimationFrame(updateFrequencyData); + if (random) { + // Use setInterval when random is true + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + intervalRef.current = window.setInterval(updateFrequencyData, interval); + } else { + // Use requestAnimationFrame when random is false + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + animationFrameRef.current = requestAnimationFrame(updateFrequencyData); + } return () => { - if (requestRef.current) { - cancelAnimationFrame(requestRef.current); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (intervalRef.current) { + clearInterval(intervalRef.current); } }; - }, [running, bars]); + }, [running, bars, random, interval]); const barWidth = `calc((100% - ${(bars - 1) * spacing}px) / ${bars})`; @@ -79,7 +120,9 @@ const Histogram: React.FC = ({ height: `${(frequencyData[index] / 255) * 100}%`, backgroundColor: 'currentColor', marginRight: index < bars - 1 ? spacing : 0, - transition: 'height 0.05s ease', + transition: random + ? `height ${interval / 1000}s ease` + : 'height 0.05s ease', }} /> ))} diff --git a/frontend/src/components/MatchingPairs/PlayCard.tsx b/frontend/src/components/MatchingPairs/PlayCard.tsx index 6f246c4f4..203da41fb 100644 --- a/frontend/src/components/MatchingPairs/PlayCard.tsx +++ b/frontend/src/components/MatchingPairs/PlayCard.tsx @@ -56,6 +56,8 @@ const PlayCard = ({ onClick, registerUserClicks, playing, section, view, showAni bars={histogramBars} backgroundColor="purple" borderRadius=".5rem" + random={true} + interval={200} /> :
( -
- -
- ), - ], -}; diff --git a/frontend/src/stories/Histogram.stories.tsx b/frontend/src/stories/Histogram.stories.tsx new file mode 100644 index 000000000..cc9e2b767 --- /dev/null +++ b/frontend/src/stories/Histogram.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta } from '@storybook/react'; +import Histogram from "../components/Histogram/Histogram"; + +const meta: Meta = { + title: "Histogram", + component: Histogram, + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +}; + +export const Default = { + args: { + bars: 7, + spacing: 6, + interval: 100, + running: true, + marginLeft: 0, + marginTop: 0, + backgroundColor: undefined, + borderRadius: "0.15rem", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Random = { + args: { + bars: 7, + spacing: 6, + interval: 100, + running: true, + marginLeft: 0, + marginTop: 0, + backgroundColor: undefined, + borderRadius: "0.15rem", + random: true, + interval: 150, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta;