Skip to content
Open
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
8 changes: 6 additions & 2 deletions src/components/VoiceAlertManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const VoiceAlertManager = () => {
voices,
config,
isSpeaking,
queue,
queueLength,
addToQueue,
clearQueue,
Expand Down Expand Up @@ -197,7 +198,10 @@ const VoiceAlertManager = () => {
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Alert Queue</span>
<span className="text-xs font-bold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 px-2 py-1 rounded-full">
<span
aria-live="polite"
className="text-xs font-bold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 px-2 py-1 rounded-full"
>
{queueLength} pending
</span>
</div>
Expand All @@ -207,7 +211,7 @@ const VoiceAlertManager = () => {
) : (
<ul className="space-y-2 max-h-40 overflow-y-auto">
{queue.map((item, idx) => (
<li key={item.id} className="text-sm p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700">
<li key={item.id ?? idx} className="text-sm p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700">
<div className="flex justify-between">
<span className="font-medium text-gray-900 dark:text-white truncate">{item.message}</span>
{idx === 0 && isSpeaking && (
Expand Down
160 changes: 160 additions & 0 deletions src/components/VoiceAlertManager.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';

import VoiceAlertManager from './VoiceAlertManager';

/**
* jsdom has no Web Speech API, so the service is stubbed. Everything below is about what
* the panel renders, not about speech synthesis.
*/
vi.mock('../services/speechService', () => ({
isSpeechSupported: vi.fn(() => true),
getAvailableVoices: vi.fn(() => Promise.resolve([])),
speakText: vi.fn(() => Promise.resolve()),
stopSpeech: vi.fn(),
}));

import { getAvailableVoices, isSpeechSupported, speakText } from '../services/speechService';

/** Holds `speakText` open so the panel can be inspected mid-utterance. */
function deferredSpeak() {
let release;
const gate = new Promise((resolve) => { release = resolve; });
speakText.mockImplementationOnce(() => gate);
return () => { release(); return gate; };
}

async function renderPanel() {
const view = render(<VoiceAlertManager />);
await act(async () => { });
return view;
}

const queueRegion = () => screen.getByText('Alert Queue').closest('div').parentElement;

beforeEach(() => {
vi.clearAllMocks();
isSpeechSupported.mockReturnValue(true);
getAvailableVoices.mockResolvedValue([]);
speakText.mockResolvedValue(undefined);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('VoiceAlertManager', () => {
it('renders the empty queue state on load', async () => {
await renderPanel();

expect(screen.getByText('Voice-Guided Accessibility Suite')).toBeInTheDocument();
expect(screen.getByText('0 pending')).toBeInTheDocument();
expect(screen.getByText('Queue is empty.')).toBeInTheDocument();
});

it('renders the pending alert instead of throwing "queue is not defined" (#1136)', async () => {
const release = deferredSpeak();
await renderPanel();

// Before the fix this render threw ReferenceError: queue is not defined, because
// the hook only ever returned queueLength and the else-branch reads `queue`.
fireEvent.click(screen.getByRole('button', { name: /test voice/i }));

await waitFor(() => expect(screen.getByText('1 pending')).toBeInTheDocument());
expect(screen.queryByText('Queue is empty.')).not.toBeInTheDocument();

const items = within(queueRegion()).getAllByRole('listitem');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('Air quality alert: PM2.5 levels are currently moderate.');
expect(items[0]).toHaveTextContent('Priority: MODERATE');

await act(async () => { await release(); });
});

it('marks the alert being spoken as PLAYING', async () => {
const release = deferredSpeak();
await renderPanel();

fireEvent.click(screen.getByRole('button', { name: /test voice/i }));

await waitFor(() => expect(screen.getByText('PLAYING')).toBeInTheDocument());

await act(async () => { await release(); });
await waitFor(() => expect(screen.getByText('Queue is empty.')).toBeInTheDocument());
});

it('renders a simulated critical alert with its priority and threshold', async () => {
const release = deferredSpeak();
await renderPanel();

fireEvent.click(screen.getByRole('button', { name: /simulate critical aqi alert/i }));

await waitFor(() => expect(screen.getByText('1 pending')).toBeInTheDocument());
const [row] = within(queueRegion()).getAllByRole('listitem');
expect(row).toHaveTextContent('Critical pollution alert. AQI has exceeded 100 in your area.');
expect(row).toHaveTextContent('Priority: CRITICAL');

await act(async () => { await release(); });
});

it('queues a second critical alert behind the one being spoken', async () => {
const release = deferredSpeak();
await renderPanel();

fireEvent.click(screen.getByRole('button', { name: /simulate critical aqi alert/i }));
await waitFor(() => expect(screen.getByText('1 pending')).toBeInTheDocument());

// Test Voice is correctly disabled mid-utterance, so the critical button is the
// only way to add another alert here. Same priority keeps arrival order.
expect(screen.getByRole('button', { name: /speaking/i })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: /simulate critical aqi alert/i }));

await waitFor(() => expect(screen.getByText('2 pending')).toBeInTheDocument());
const rows = within(queueRegion()).getAllByRole('listitem');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('PLAYING');
expect(rows[1]).not.toHaveTextContent('PLAYING');

await act(async () => { await release(); });
});

it('clears a queued alert when Clear Queue is pressed', async () => {
const release = deferredSpeak();
await renderPanel();

fireEvent.click(screen.getByRole('button', { name: /test voice/i }));
await waitFor(() => expect(screen.getByText('1 pending')).toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: /clear queue/i }));

await waitFor(() => expect(screen.getByText('0 pending')).toBeInTheDocument());
expect(screen.getByText('Queue is empty.')).toBeInTheDocument();

await act(async () => { await release(); });
});

it('disables Clear Queue while there is nothing to clear', async () => {
await renderPanel();
expect(screen.getByRole('button', { name: /clear queue/i })).toBeDisabled();
});

it('tells the visitor when the browser has no speech synthesis', async () => {
isSpeechSupported.mockReturnValue(false);
await renderPanel();

expect(screen.getByText('Voice Features Unavailable')).toBeInTheDocument();
expect(screen.queryByText('Alert Queue')).not.toBeInTheDocument();
});

it('lists the voices the service reports', async () => {
getAvailableVoices.mockResolvedValue([
{ voiceURI: 'uri-a', name: 'Aditi', lang: 'en-IN' },
{ voiceURI: 'uri-b', name: 'Brian', lang: 'en-GB' },
]);
await renderPanel();

const select = screen.getByRole('combobox');
expect(within(select).getByRole('option', { name: 'Aditi (en-IN)' })).toBeInTheDocument();
expect(within(select).getByRole('option', { name: 'Brian (en-GB)' })).toBeInTheDocument();
});
});
103 changes: 93 additions & 10 deletions src/hooks/useVoiceSynthesis.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,69 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { isSpeechSupported, getAvailableVoices, speakText, stopSpeech } from '../services/speechService';

/**
* Alert priorities, most urgent first.
*
* The order of this array *is* the ordering rule — `priorityRank` is derived from it, so
* adding a level is a one-line change rather than a second table to keep in sync.
*/
export const ALERT_PRIORITIES = ['CRITICAL', 'HIGH', 'MODERATE', 'LOW'];

/** Anything unrecognised sorts after every known level rather than jumping the queue. */
const UNKNOWN_PRIORITY_RANK = ALERT_PRIORITIES.length;

/**
* Where an alert sits in the ordering. Lower is more urgent.
*
* @param {unknown} priority
* @returns {number}
*/
export function priorityRank(priority) {
if (typeof priority !== 'string') return UNKNOWN_PRIORITY_RANK;
const index = ALERT_PRIORITIES.indexOf(priority.toUpperCase());
return index === -1 ? UNKNOWN_PRIORITY_RANK : index;
}

/**
* Places `alert` in the queue by priority, keeping arrival order within a priority.
*
* `addToQueue` used to be a plain `[...prev, alert]` append, so a CRITICAL pollution alert
* waited behind however many routine messages were already queued — the wrong end of the
* queue for a feature meant to be listened to rather than watched.
*
* The item at index 0 is skipped while `isSpeaking` is true: it is mid-utterance, and
* moving it would leave the row the panel marks "PLAYING" pointing at the wrong alert.
* Interrupting speech that is already underway is `clearQueue`'s job, not an insert's.
*
* @template {{priority?: string}} T
* @param {T[]} queue - The current queue.
* @param {T} alert - The alert to place.
* @param {boolean} [isSpeaking=false] - Whether the head of the queue is being spoken.
* @returns {T[]} A new queue.
*/
export function insertByPriority(queue, alert, isSpeaking = false) {
const rank = priorityRank(alert?.priority);
const firstMovable = isSpeaking && queue.length > 0 ? 1 : 0;

let insertAt = queue.length;
for (let i = firstMovable; i < queue.length; i++) {
if (priorityRank(queue[i]?.priority) > rank) {
insertAt = i;
break;
}
}

return [...queue.slice(0, insertAt), alert, ...queue.slice(insertAt)];
}

/**
* @hook useVoiceSynthesis
* @description Custom React hook managing the speech queue, language voice selection, and playback state.
*
* Returns the queue itself as well as its length. `VoiceAlertManager` renders the pending
* list with `queue.map(...)` and the hook only ever exposed `queueLength`, so the panel
* threw `ReferenceError: queue is not defined` the first time anything was queued — the
* `no-undef` ESLint has been reporting on that file. See #1136.
*/
export const useVoiceSynthesis = (initialConfig) => {
const [isSupported, setIsSupported] = useState(false);
Expand All @@ -14,18 +74,20 @@ export const useVoiceSynthesis = (initialConfig) => {
const isProcessingRef = useRef(false);

useEffect(() => {
let cancelled = false;
const supported = isSpeechSupported();
setIsSupported(supported);
if (supported) {
getAvailableVoices().then(setVoices);
}
}, []);
if (!supported) return undefined;

useEffect(() => {
if (queue.length > 0 && !isProcessingRef.current && config.isEnabled) {
processQueue();
}
}, [queue, config.isEnabled]);
// Guarded because the voice list arrives asynchronously and can outlive the mount.
getAvailableVoices().then((available) => {
if (!cancelled) setVoices(available);
});

return () => {
cancelled = true;
};
}, []);

const processQueue = useCallback(async () => {
if (queue.length === 0 || isProcessingRef.current) return;
Expand All @@ -45,8 +107,28 @@ export const useVoiceSynthesis = (initialConfig) => {
}
}, [queue, config]);

// `processQueue` belongs in the dependency list: it closes over `queue` and `config`,
// and leaving it out was the exhaustive-deps warning on this file. Including it means
// a config change mid-queue re-evaluates against the new config instead of the one
// captured when the queue last changed; `isProcessingRef` stops that re-entering an
// utterance already in flight.
useEffect(() => {
if (queue.length > 0 && !isProcessingRef.current && config.isEnabled) {
processQueue();
}
}, [queue, config.isEnabled, processQueue]);

// Turning alerts off should stop the one being spoken, not just decline to start the
// next. Otherwise the toggle appears to do nothing until the current message ends.
useEffect(() => {
if (!config.isEnabled && isProcessingRef.current) {
stopSpeech();
}
}, [config.isEnabled]);

const addToQueue = useCallback((alert) => {
setQueue(prev => [...prev, alert]);
if (!alert || typeof alert.message !== 'string' || alert.message.trim() === '') return;
setQueue(prev => insertByPriority(prev, alert, isProcessingRef.current));
}, []);

const clearQueue = useCallback(() => {
Expand All @@ -65,6 +147,7 @@ export const useVoiceSynthesis = (initialConfig) => {
voices,
config,
isSpeaking,
queue,
queueLength: queue.length,
addToQueue,
clearQueue,
Expand Down
Loading
Loading