halkyon/packages/halkyon_app/test/speech/dictation_test.dart

224 lines
7.4 KiB
Dart
Raw Normal View History

refactor(app): the device's speech recognition moves from Learn into halkyon_app, with dictation into a text Learn's `SpeechInput` and `DeviceSpeechInput` (ADR 0021, D67) are halkyon_app's now, and its `FakeSpeechInput` is in `testing.dart`, so Meditate's dream journal (ADR 0038, D15) listens through the same code. Learn imports them from there and behaves as before: its composer and its tests are unchanged but for the imports, and `DeviceSpeechInput()` asks for what it asked before. `DeviceSpeechInput` takes three options, off by default: - `preferOnDevice`: on Android, recognition on the device where it can (Android 12 on), else offline; should that fail before a word is heard (not a pause), it listens as Android otherwise does from then on. - `pauseFor`: how long a pause may last before the recogniser stops. - `listenFor`: how long one listen may last at most. `Dictation` writes what is heard where the cursor stands (`DictationInsertion`: in place of a selection, set apart by a space), shows the words so far, and listens again after each pause while it is on, stopping by itself after three listens running heard nothing. speech_to_text moves from Learn's pubspec to halkyon_app's, the lock unchanged. Todo, Train and Meditate link the plugin now; their generated plugin registrants (macOS, Windows) say so. An app declares the microphone itself, and Todo and Train declare none, so nothing there can listen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 14:41:07 +01:00
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
/// Dictating into a text (Halkyon Meditate's ADR 0038, D15): the words
/// heard go where the cursor stands, set apart by a space, and listening
/// starts again after the recogniser's pause while the person leaves it
/// on, until it hears nothing a few times running.
void main() {
group('the words heard go', () {
TextEditingValue at(String text, int offset) => TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: offset),
);
test('after the text, a space between, the cursor after them', () {
final value = DictationInsertion.at(at('I was', 5)).withWords('at sea');
expect(value.text, 'I was at sea');
expect(value.selection, const TextSelection.collapsed(offset: 12));
});
test('where the cursor stands, set apart on both sides', () {
final value = DictationInsertion.at(
at('I was there.', 5),
).withWords('at sea');
expect(value.text, 'I was at sea there.');
expect(value.selection, const TextSelection.collapsed(offset: 12));
});
test('with no space added beside one there already, nor at the '
"text's ends", () {
expect(
DictationInsertion.at(at('', 0)).withWords('A house').text,
'A house',
);
expect(
DictationInsertion.at(at('I was ', 6)).withWords('home').text,
'I was home',
);
expect(
DictationInsertion.at(at(' there', 0)).withWords('Out').text,
'Out there',
);
});
test('in place of what is selected, as typing does', () {
final value = DictationInsertion.at(
const TextEditingValue(
text: 'a red house',
selection: TextSelection(baseOffset: 2, extentOffset: 5),
),
).withWords('blue');
expect(value.text, 'a blue house');
});
test('at the end where the field has no cursor yet', () {
expect(
DictationInsertion.at(const TextEditingValue(text: 'I was'))
.withWords('home')
.text,
'I was home',
);
});
test('nothing heard leaves the text as it was', () {
expect(DictationInsertion.at(at('I was', 5)).withWords('').text, 'I was');
});
});
group('a dictation', () {
late FakeSpeechInput speech;
late TextEditingController text;
late Dictation dictation;
setUp(() {
speech = FakeSpeechInput();
text = TextEditingController();
dictation = Dictation(speech, text);
});
tearDown(() {
dictation.dispose();
text.dispose();
});
test('listens in the language asked, writing the words as they come, '
'and shows them', () async {
expect(await dictation.start(language: 'de'), SpeechStart.listening);
refactor(app): the device's speech recognition moves from Learn into halkyon_app, with dictation into a text Learn's `SpeechInput` and `DeviceSpeechInput` (ADR 0021, D67) are halkyon_app's now, and its `FakeSpeechInput` is in `testing.dart`, so Meditate's dream journal (ADR 0038, D15) listens through the same code. Learn imports them from there and behaves as before: its composer and its tests are unchanged but for the imports, and `DeviceSpeechInput()` asks for what it asked before. `DeviceSpeechInput` takes three options, off by default: - `preferOnDevice`: on Android, recognition on the device where it can (Android 12 on), else offline; should that fail before a word is heard (not a pause), it listens as Android otherwise does from then on. - `pauseFor`: how long a pause may last before the recogniser stops. - `listenFor`: how long one listen may last at most. `Dictation` writes what is heard where the cursor stands (`DictationInsertion`: in place of a selection, set apart by a space), shows the words so far, and listens again after each pause while it is on, stopping by itself after three listens running heard nothing. speech_to_text moves from Learn's pubspec to halkyon_app's, the lock unchanged. Todo, Train and Meditate link the plugin now; their generated plugin registrants (macOS, Windows) say so. An app declares the microphone itself, and Todo and Train declare none, so nothing there can listen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 14:41:07 +01:00
expect(dictation.listening, isTrue);
expect(speech.languages, ['de']);
speech.say('Ich war');
expect(dictation.heard, 'Ich war');
speech.say('Ich war am Meer');
expect(text.text, 'Ich war am Meer');
});
test('stopped, it listens no more and keeps what it wrote', () async {
await dictation.start(language: 'en');
speech.say('A house');
await dictation.stop();
expect(dictation.listening, isFalse);
expect(speech.listening, isFalse);
expect(text.text, 'A house');
speech.say('ignored');
expect(text.text, 'A house');
});
test("after the recogniser's pause it listens again, the next words "
'after the last', () async {
await dictation.start(language: 'en');
speech
..say('A house')
..finish();
await pumpEventQueue();
expect(dictation.listening, isTrue);
expect(speech.listening, isTrue);
expect(speech.languages, ['en', 'en']);
speech.say('by the sea');
expect(text.text, 'A house by the sea');
});
test(
'it stops by itself after hearing nothing three times running',
() async {
await dictation.start(language: 'en');
for (var pause = 0; pause < Dictation.silencesBeforeStopping; pause++) {
expect(dictation.listening, isTrue, reason: 'pause $pause');
speech.finish();
await pumpEventQueue();
}
expect(dictation.listening, isFalse);
expect(speech.listening, isFalse);
},
);
test('words heard count the silences again from none', () async {
await dictation.start(language: 'en');
speech.finish();
await pumpEventQueue();
speech
..say('A house')
..finish();
await pumpEventQueue();
speech.finish();
await pumpEventQueue();
expect(dictation.listening, isTrue);
});
test('where the device cannot listen, it says so and stays off', () async {
speech.listens = false;
expect(await dictation.start(language: 'en'), SpeechStart.unavailable);
refactor(app): the device's speech recognition moves from Learn into halkyon_app, with dictation into a text Learn's `SpeechInput` and `DeviceSpeechInput` (ADR 0021, D67) are halkyon_app's now, and its `FakeSpeechInput` is in `testing.dart`, so Meditate's dream journal (ADR 0038, D15) listens through the same code. Learn imports them from there and behaves as before: its composer and its tests are unchanged but for the imports, and `DeviceSpeechInput()` asks for what it asked before. `DeviceSpeechInput` takes three options, off by default: - `preferOnDevice`: on Android, recognition on the device where it can (Android 12 on), else offline; should that fail before a word is heard (not a pause), it listens as Android otherwise does from then on. - `pauseFor`: how long a pause may last before the recogniser stops. - `listenFor`: how long one listen may last at most. `Dictation` writes what is heard where the cursor stands (`DictationInsertion`: in place of a selection, set apart by a space), shows the words so far, and listens again after each pause while it is on, stopping by itself after three listens running heard nothing. speech_to_text moves from Learn's pubspec to halkyon_app's, the lock unchanged. Todo, Train and Meditate link the plugin now; their generated plugin registrants (macOS, Windows) say so. An app declares the microphone itself, and Todo and Train declare none, so nothing there can listen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 14:41:07 +01:00
expect(dictation.listening, isFalse);
});
test('where no recogniser is installed, it says so and stays off, '
'never stuck listening', () async {
speech.hasRecognizer = false;
var told = 0;
dictation.addListener(() => told++);
expect(await dictation.start(language: 'en'), SpeechStart.noRecognizer);
expect(dictation.listening, isFalse);
expect(told, 2, reason: 'on, then off again');
});
2026-11-07 23:45:20 +01:00
group('through a voice input app (ADR 0038, D18)', () {
setUp(() => speech.app = true);
test('writes what it returns where the cursor stood, once, and '
'listens no more', () async {
text.value = const TextEditingValue(
text: 'I was there.',
selection: TextSelection.collapsed(offset: 5),
);
final started = dictation.start(language: 'en', prompt: 'Your dream');
await pumpEventQueue();
expect(dictation.listening, isTrue, reason: 'while the app is open');
expect(speech.appOpen, isTrue);
expect(speech.listening, isFalse, reason: 'nothing else listens');
expect(speech.prompts, ['Your dream']);
speech.answer('at sea');
expect(await started, SpeechStart.finished);
expect(text.text, 'I was at sea there.');
expect(text.selection, const TextSelection.collapsed(offset: 12));
expect(dictation.listening, isFalse);
expect(speech.languages, ['en'], reason: 'not begun again');
});
test('cancelled, writes nothing', () async {
text.text = 'I was';
final started = dictation.start(language: 'en');
await pumpEventQueue();
speech.cancel();
expect(await started, SpeechStart.finished);
expect(text.text, 'I was');
expect(dictation.listening, isFalse);
});
test('stopped while the app is open, writes nothing it returns '
'later', () async {
final started = dictation.start(language: 'en');
await pumpEventQueue();
await dictation.stop();
expect(dictation.listening, isFalse);
speech.answer('late words');
await started;
expect(text.text, isEmpty);
});
});
refactor(app): the device's speech recognition moves from Learn into halkyon_app, with dictation into a text Learn's `SpeechInput` and `DeviceSpeechInput` (ADR 0021, D67) are halkyon_app's now, and its `FakeSpeechInput` is in `testing.dart`, so Meditate's dream journal (ADR 0038, D15) listens through the same code. Learn imports them from there and behaves as before: its composer and its tests are unchanged but for the imports, and `DeviceSpeechInput()` asks for what it asked before. `DeviceSpeechInput` takes three options, off by default: - `preferOnDevice`: on Android, recognition on the device where it can (Android 12 on), else offline; should that fail before a word is heard (not a pause), it listens as Android otherwise does from then on. - `pauseFor`: how long a pause may last before the recogniser stops. - `listenFor`: how long one listen may last at most. `Dictation` writes what is heard where the cursor stands (`DictationInsertion`: in place of a selection, set apart by a space), shows the words so far, and listens again after each pause while it is on, stopping by itself after three listens running heard nothing. speech_to_text moves from Learn's pubspec to halkyon_app's, the lock unchanged. Todo, Train and Meditate link the plugin now; their generated plugin registrants (macOS, Windows) say so. An app declares the microphone itself, and Todo and Train declare none, so nothing there can listen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 14:41:07 +01:00
test('tells its listeners as it starts, hears and stops', () async {
var told = 0;
dictation.addListener(() => told++);
await dictation.start(language: 'en');
speech.say('A');
await dictation.stop();
expect(told, greaterThanOrEqualTo(3));
});
});
}