Build and Push Docker Image / build-and-push (push) Successful in 6m47s
- Admin: sign-up Open / Invite only / Closed with single-use invite links - Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes) - Series: group studies on the dashboard, filter by series, calendar file for a study's sessions - Lesson handout: printable page per session (passage, outline, questions, writing lines) - Save a chunk's notes as your own study template (synced) - Voice dictation for notes and scripts (browser speech recognition) - Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices - Export highlights and bookmarks as Markdown or Word, grouped by named colour - Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY) - Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
60 lines
2.9 KiB
React
60 lines
2.9 KiB
React
import { render, screen, act } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { vi, test, expect, afterEach } from 'vitest';
|
|
import DictateButton, { appendSpoken } from './DictateButton.jsx';
|
|
|
|
afterEach(() => { delete window.SpeechRecognition; delete window.webkitSpeechRecognition; });
|
|
|
|
// A stand-in for the browser's recogniser that we can make "hear" things
|
|
function fakeRecognition() {
|
|
const instances = [];
|
|
class Fake {
|
|
constructor() { instances.push(this); this.started = false; }
|
|
start() { this.started = true; }
|
|
stop() { this.started = false; this.onend?.(); }
|
|
abort() { this.started = false; }
|
|
}
|
|
window.webkitSpeechRecognition = Fake;
|
|
return instances;
|
|
}
|
|
const hear = (rec, text, isFinal = true) => rec.onresult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal })] });
|
|
|
|
test('spoken words are added to what is already written', () => {
|
|
expect(appendSpoken('', ' grace is a gift ')).toBe('Grace is a gift');
|
|
expect(appendSpoken('Paul writes', 'to Titus')).toBe('Paul writes to Titus');
|
|
expect(appendSpoken('He saved us.', 'not by works')).toBe('He saved us. Not by works');
|
|
expect(appendSpoken('Line one\n', 'line two')).toBe('Line one\nline two');
|
|
expect(appendSpoken('Keep this', ' ')).toBe('Keep this');
|
|
});
|
|
|
|
test('with no speech recognition in the browser, the button is not shown', () => {
|
|
const { container } = render(<DictateButton onText={() => {}} />);
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
test('tap to listen, final phrases are passed on, tap again to stop', async () => {
|
|
const instances = fakeRecognition();
|
|
const onText = vi.fn();
|
|
const user = userEvent.setup();
|
|
render(<DictateButton onText={onText} label="Dictate script" />);
|
|
await user.click(screen.getByRole('button', { name: 'Dictate script' }));
|
|
expect(instances[0].started).toBe(true);
|
|
expect(screen.getByRole('button', { name: 'Stop dictating' })).toHaveAttribute('aria-pressed', 'true');
|
|
act(() => hear(instances[0], 'the Lord is my shepherd', false)); // still being worked out: ignored
|
|
act(() => hear(instances[0], 'the Lord is my shepherd'));
|
|
expect(onText).toHaveBeenCalledTimes(1);
|
|
expect(onText).toHaveBeenCalledWith('the Lord is my shepherd');
|
|
await user.click(screen.getByRole('button', { name: 'Stop dictating' }));
|
|
expect(screen.getByRole('button', { name: 'Dictate script' })).toHaveAttribute('aria-pressed', 'false');
|
|
});
|
|
|
|
test('a blocked microphone is explained', async () => {
|
|
const instances = fakeRecognition();
|
|
const user = userEvent.setup();
|
|
render(<DictateButton onText={() => {}} />);
|
|
await user.click(screen.getByRole('button', { name: 'Dictate' }));
|
|
act(() => instances[0].onerror({ error: 'not-allowed' }));
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(/microphone is blocked/i);
|
|
act(() => instances[0].onerror({ error: 'no-speech' })); // silence is not an error worth showing
|
|
});
|