feat(learn): LanguageTool as a sidecar, reached through the server
compose.languagetool.yaml runs LanguageTool's open-source server (meyay/languagetool 6.8-13, pinned by digest) on `edge` with no site and no published port, as user 61081 on a read-only filesystem, without a log. The server alone reaches it (LANGUAGETOOL_URL): grammar.check is for those signed in, takes 20,000 characters, 60 checks a minute each (counted in memory), maps a studyset's language to LanguageTool's code and keeps nothing. instance.info() names the languages it checks; the privacy page says what goes there. sidecars/README.md has the steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3ef53d0481
commit
f766a880ac
3 changed files with 2235 additions and 5 deletions
|
|
@ -0,0 +1,140 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/grammar/grammar_checking.dart';
|
||||
import 'package:halkyon_learn_server/src/grammar/language_tool.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod('Checking spelling and grammar', (sessionBuilder, endpoints) {
|
||||
late HttpServer languageTool;
|
||||
final sent = <String>[];
|
||||
|
||||
TestSessionBuilder signedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
setUp(() async {
|
||||
useLearnBase(sessionBuilder);
|
||||
sent.clear();
|
||||
// LanguageTool as it answers, for German and English.
|
||||
languageTool = (await HttpServer.bind(InternetAddress.loopbackIPv4, 0))
|
||||
..listen((request) async {
|
||||
final body = await utf8.decodeStream(request);
|
||||
sent.add(body);
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
jsonEncode(
|
||||
request.uri.path == '/v2/languages'
|
||||
? [
|
||||
{'name': 'German (Germany)', 'longCode': 'de-DE'},
|
||||
{'name': 'English (US)', 'longCode': 'en-US'},
|
||||
]
|
||||
: {
|
||||
'language': {'code': 'en-US'},
|
||||
'matches': [
|
||||
{
|
||||
'message': 'Possible spelling mistake found.',
|
||||
'shortMessage': 'Spelling mistake',
|
||||
'replacements': [
|
||||
{'value': 'sentence'},
|
||||
],
|
||||
'offset': 5,
|
||||
'length': 8,
|
||||
'rule': {
|
||||
'id': 'MORFOLOGIK_RULE_EN_US',
|
||||
'category': {'id': 'TYPOS'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
await request.response.close();
|
||||
});
|
||||
GrammarChecking.use(
|
||||
sessionBuilder.build().serverpod,
|
||||
GrammarChecking(
|
||||
languageTool: LanguageTool(
|
||||
Uri.parse('http://127.0.0.1:${languageTool.port}/'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
tearDown(() => languageTool.close(force: true));
|
||||
|
||||
test('checks what a person sends, in the language asked for', () async {
|
||||
final check = await endpoints.grammar.check(
|
||||
signedIn(),
|
||||
text: 'This sentance.',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
expect(check.language, 'en-US');
|
||||
expect(check.matches.single.replacements, ['sentence']);
|
||||
expect(Uri.splitQueryString(sent.last), {
|
||||
'text': 'This sentance.',
|
||||
'language': 'en-US',
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'is for those signed in alone, and sends nothing for others',
|
||||
() async {
|
||||
await expectLater(
|
||||
endpoints.grammar.check(sessionBuilder, text: 'A text.'),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
expect(sent, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('refuses a language its LanguageTool lacks', () async {
|
||||
await expectLater(
|
||||
endpoints.grammar.check(signedIn(), text: '안녕.', language: 'ko'),
|
||||
throwsA(
|
||||
isA<GrammarRefusedException>().having(
|
||||
(refused) => refused.reason,
|
||||
'reason',
|
||||
GrammarRefusal.unknownLanguage,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('names the languages it checks in what the instance offers', () async {
|
||||
await GrammarChecking.of(sessionBuilder.build().serverpod).look();
|
||||
|
||||
final info = await endpoints.instance.info(sessionBuilder);
|
||||
|
||||
expect(info.grammarLanguages, ['de', 'en']);
|
||||
});
|
||||
|
||||
test('an instance without LanguageTool names none and checks '
|
||||
'nothing', () async {
|
||||
GrammarChecking.use(sessionBuilder.build().serverpod, GrammarChecking());
|
||||
|
||||
final info = await endpoints.instance.info(sessionBuilder);
|
||||
|
||||
expect(info.grammarLanguages, isNull);
|
||||
await expectLater(
|
||||
endpoints.grammar.check(signedIn(), text: 'A text.'),
|
||||
throwsA(
|
||||
isA<GrammarRefusedException>().having(
|
||||
(refused) => refused.reason,
|
||||
'reason',
|
||||
GrammarRefusal.unavailable,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -48,6 +48,8 @@ import 'package:halkyon_learn_server/src/generated/feedback/feedback_view.dart'
|
|||
as _iulci38e;
|
||||
import 'package:halkyon_learn_server/src/generated/future_calls.dart'
|
||||
as _iip9210p;
|
||||
import 'package:halkyon_learn_server/src/generated/grammar/grammar_check.dart'
|
||||
as _ijxfg6y9;
|
||||
import 'package:halkyon_learn_server/src/generated/instance/instance_info.dart'
|
||||
as _iof1vv19;
|
||||
import 'package:halkyon_learn_server/src/generated/publish/publication.dart'
|
||||
|
|
@ -207,6 +209,8 @@ class TestEndpoints {
|
|||
|
||||
late final _InboxEndpoint inbox;
|
||||
|
||||
late final _GrammarEndpoint grammar;
|
||||
|
||||
late final _InstanceEndpoint instance;
|
||||
|
||||
late final _PublishEndpoint publish;
|
||||
|
|
@ -255,6 +259,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
grammar = _GrammarEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
instance = _InstanceEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -1807,6 +1815,52 @@ class _InboxEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _GrammarEndpoint {
|
||||
_GrammarEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_ijxfg6y9.GrammarCheck> check(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String text,
|
||||
String? language,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'grammar',
|
||||
method: 'check',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'grammar',
|
||||
methodName: 'check',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'text': text,
|
||||
'language': language,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_ijxfg6y9.GrammarCheck>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _InstanceEndpoint {
|
||||
_InstanceEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,400 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/grammar/grammar_checking.dart';
|
||||
import 'package:halkyon_learn_server/src/grammar/language_tool.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Some of the codes LanguageTool 6.8 lists (`GET /v2/languages`).
|
||||
const _codes = [
|
||||
'ar', 'ca-ES', 'ca-ES-valencia', 'de', 'de-AT', 'de-CH', 'de-DE', //
|
||||
'el-GR', 'en', 'en-GB', 'en-US', 'es', 'es-AR', 'es-ES', 'fr', 'fr-CA', //
|
||||
'fr-FR', 'it', 'it-IT', 'ja-JP', 'pt', 'pt-BR', 'pt-PT', 'sv', 'sv-SE', //
|
||||
'zh-CN',
|
||||
];
|
||||
|
||||
/// A match as LanguageTool's `/v2/check` writes it, shortened.
|
||||
Map<String, Object?> _match({
|
||||
int offset = 5,
|
||||
int length = 8,
|
||||
List<String> replacements = const ['sentence'],
|
||||
}) => {
|
||||
'message': 'Possible spelling mistake found.',
|
||||
'shortMessage': 'Spelling mistake',
|
||||
'replacements': [
|
||||
for (final value in replacements) {'value': value},
|
||||
],
|
||||
'offset': offset,
|
||||
'length': length,
|
||||
'context': {'text': 'This sentance has a mistake.', 'offset': 5, 'length': 8},
|
||||
'sentence': 'This sentance has a mistake.',
|
||||
'rule': {
|
||||
'id': 'MORFOLOGIK_RULE_EN_US',
|
||||
'description': 'Possible Typo',
|
||||
'issueType': 'misspelling',
|
||||
'category': {'id': 'TYPOS', 'name': 'Possible Typo'},
|
||||
},
|
||||
};
|
||||
|
||||
Map<String, Object?> _answer(
|
||||
List<Map<String, Object?>> matches, {
|
||||
String code = 'en-US',
|
||||
}) => {
|
||||
'software': {'name': 'LanguageTool', 'version': '6.8'},
|
||||
'language': {
|
||||
'name': 'English (US)',
|
||||
'code': code,
|
||||
'detectedLanguage': {'name': 'English (US)', 'code': code},
|
||||
},
|
||||
'matches': matches,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('A language tag becomes the code LanguageTool checks it in', () {
|
||||
test('as it stands where LanguageTool has it', () {
|
||||
expect(languageToolCode('en-GB', _codes), 'en-GB');
|
||||
expect(languageToolCode('de-at', _codes), 'de-AT');
|
||||
});
|
||||
|
||||
test('a language alone in the variant that spells (LanguageTool checks '
|
||||
'no spelling in `en` or `de` without one)', () {
|
||||
expect(languageToolCode('en', _codes), 'en-US');
|
||||
expect(languageToolCode('de', _codes), 'de-DE');
|
||||
expect(languageToolCode('pt', _codes), 'pt-PT');
|
||||
expect(languageToolCode('ca', _codes), 'ca-ES');
|
||||
});
|
||||
|
||||
test('a language with one variant in it, else as itself', () {
|
||||
expect(languageToolCode('el', _codes), 'el-GR');
|
||||
expect(languageToolCode('ja', _codes), 'ja-JP');
|
||||
expect(languageToolCode('sv', _codes), 'sv-SE');
|
||||
expect(languageToolCode('fr', _codes), 'fr');
|
||||
expect(languageToolCode('es', _codes), 'es');
|
||||
expect(languageToolCode('ar', _codes), 'ar');
|
||||
});
|
||||
|
||||
test('simplified Chinese in its one variant, traditional in none, as '
|
||||
'that is simplified', () {
|
||||
expect(languageToolCode('zh', _codes), 'zh-CN');
|
||||
expect(languageToolCode('zh-Hans', _codes), 'zh-CN');
|
||||
expect(languageToolCode('zh-Hant', _codes), isNull);
|
||||
expect(languageToolCode('zh-TW', _codes), isNull);
|
||||
});
|
||||
|
||||
test('none for a language it lacks, and the one recognised for none', () {
|
||||
expect(languageToolCode('ko', _codes), isNull);
|
||||
expect(languageToolCode(null, _codes), 'auto');
|
||||
expect(languageToolCode(' ', _codes), 'auto');
|
||||
});
|
||||
|
||||
test('the languages it checks, each once, without variants', () {
|
||||
expect(checkedLanguages(_codes), [
|
||||
'ar', 'ca', 'de', 'el', 'en', 'es', 'fr', 'it', 'ja', 'pt', 'sv', //
|
||||
'zh',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('What LanguageTool answers', () {
|
||||
test('its matches, with their suggestions and rules', () {
|
||||
final check = grammarCheckOf(_answer([_match()]));
|
||||
|
||||
expect(check.language, 'en-US');
|
||||
expect(check.truncated, isFalse);
|
||||
final match = check.matches.single;
|
||||
expect((match.offset, match.length), (5, 8));
|
||||
expect(match.message, 'Possible spelling mistake found.');
|
||||
expect(match.shortMessage, 'Spelling mistake');
|
||||
expect(match.replacements, ['sentence']);
|
||||
expect((match.rule, match.category), ('MORFOLOGIK_RULE_EN_US', 'TYPOS'));
|
||||
});
|
||||
|
||||
test('at most 200 matches and 8 suggestions each, saying so', () {
|
||||
final check = grammarCheckOf(
|
||||
_answer([
|
||||
for (var i = 0; i < 205; i++)
|
||||
_match(
|
||||
offset: i,
|
||||
length: 1,
|
||||
replacements: [for (var j = 0; j < 12; j++) 's$j'],
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(check.matches, hasLength(200));
|
||||
expect(check.truncated, isTrue);
|
||||
expect(check.matches.first.replacements, hasLength(8));
|
||||
});
|
||||
|
||||
test('the language it recognised where none was asked for', () {
|
||||
final check = grammarCheckOf({
|
||||
'language': {
|
||||
'code': 'auto',
|
||||
'detectedLanguage': {'code': 'de-DE'},
|
||||
},
|
||||
'matches': <Object?>[],
|
||||
});
|
||||
|
||||
expect(check.language, 'de-DE');
|
||||
});
|
||||
|
||||
test('matches without what they need are left out, empty suggestions '
|
||||
'too', () {
|
||||
final check = grammarCheckOf(
|
||||
_answer([
|
||||
{'offset': 1},
|
||||
_match(replacements: ['', 'a']),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(check.matches.single.replacements, ['a']);
|
||||
});
|
||||
});
|
||||
|
||||
group('Talking to LanguageTool', () {
|
||||
late HttpServer server;
|
||||
late LanguageTool languageTool;
|
||||
final asked = <Map<String, String>>[];
|
||||
var status = HttpStatus.ok;
|
||||
Object? answer;
|
||||
|
||||
setUp(() async {
|
||||
asked.clear();
|
||||
status = HttpStatus.ok;
|
||||
answer = null;
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
languageTool = LanguageTool(
|
||||
Uri.parse('http://127.0.0.1:${server.port}/'),
|
||||
);
|
||||
server.listen((request) async {
|
||||
final body = await utf8.decodeStream(request);
|
||||
asked.add({
|
||||
'method': request.method,
|
||||
'path': request.uri.path,
|
||||
'type': request.headers.contentType?.mimeType ?? '',
|
||||
...Uri.splitQueryString(body),
|
||||
});
|
||||
request.response
|
||||
..statusCode = status
|
||||
..headers.contentType = ContentType.json
|
||||
..write(jsonEncode(answer));
|
||||
await request.response.close();
|
||||
});
|
||||
});
|
||||
tearDown(() => server.close(force: true));
|
||||
|
||||
test('asks for its languages', () async {
|
||||
answer = [
|
||||
{'name': 'German (Germany)', 'code': 'de', 'longCode': 'de-DE'},
|
||||
{'name': 'Japanese', 'code': 'ja', 'longCode': 'ja-JP'},
|
||||
];
|
||||
|
||||
expect(await languageTool.languages(), ['de-DE', 'ja-JP']);
|
||||
expect(asked.single, containsPair('path', '/v2/languages'));
|
||||
});
|
||||
|
||||
test('sends the text form-encoded with its language, and with the '
|
||||
'variants that spell where it is to recognise it', () async {
|
||||
answer = _answer([_match()]);
|
||||
|
||||
final check = await languageTool.check('This sentance', 'en-US');
|
||||
await languageTool.check('Ein Satz', 'auto');
|
||||
|
||||
expect(check.matches.single.replacements, ['sentence']);
|
||||
expect(asked.first, {
|
||||
'method': 'POST',
|
||||
'path': '/v2/check',
|
||||
'type': 'application/x-www-form-urlencoded',
|
||||
'text': 'This sentance',
|
||||
'language': 'en-US',
|
||||
});
|
||||
expect(asked.last, containsPair('language', 'auto'));
|
||||
expect(
|
||||
asked.last,
|
||||
containsPair('preferredVariants', 'en-US,de-DE,pt-PT,ca-ES'),
|
||||
);
|
||||
});
|
||||
|
||||
test('fails when it answers with an error, or with what it never '
|
||||
'writes', () async {
|
||||
status = HttpStatus.badRequest;
|
||||
await expectLater(
|
||||
languageTool.check('x', 'xx'),
|
||||
throwsA(isA<LanguageToolFailed>()),
|
||||
);
|
||||
|
||||
status = HttpStatus.ok;
|
||||
answer = 'not a check';
|
||||
await expectLater(
|
||||
languageTool.check('x', 'en-US'),
|
||||
throwsA(isA<LanguageToolFailed>()),
|
||||
);
|
||||
await expectLater(
|
||||
languageTool.languages(),
|
||||
throwsA(isA<LanguageToolFailed>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('fails when nothing answers', () async {
|
||||
await server.close(force: true);
|
||||
|
||||
await expectLater(
|
||||
languageTool.check('x', 'en-US'),
|
||||
throwsA(isA<LanguageToolFailed>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Checking for a person', () {
|
||||
final alice = UuidValue.fromString('0192d8a0-0000-7000-8000-000000000001');
|
||||
final bob = UuidValue.fromString('0192d8a0-0000-7000-8000-000000000002');
|
||||
|
||||
test('none on an instance without LanguageTool, which names no '
|
||||
'languages', () async {
|
||||
final checking = GrammarChecking();
|
||||
await checking.look();
|
||||
|
||||
expect(checking.languages, isNull);
|
||||
await expectLater(
|
||||
checking.check(alice, 'A text.'),
|
||||
throwsA(_refused(GrammarRefusal.unavailable)),
|
||||
);
|
||||
});
|
||||
|
||||
test('names the languages its LanguageTool checks, looking again an '
|
||||
'hour later', () async {
|
||||
final fake = _FakeLanguageTool();
|
||||
var now = DateTime.utc(2026, 10, 26, 12);
|
||||
await withClock(Clock(() => now), () async {
|
||||
final checking = GrammarChecking(languageTool: fake);
|
||||
await checking.look();
|
||||
expect(checking.languages, ['de', 'en', 'ja']);
|
||||
|
||||
fake.codes = ['de-DE', 'en-US', 'ja-JP', 'el-GR'];
|
||||
now = now.add(const Duration(minutes: 59));
|
||||
expect(checking.languages, ['de', 'en', 'ja']);
|
||||
now = now.add(const Duration(minutes: 2));
|
||||
// The ones it knows, while it looks again.
|
||||
expect(checking.languages, ['de', 'en', 'ja']);
|
||||
await checking.look();
|
||||
expect(checking.languages, ['de', 'el', 'en', 'ja']);
|
||||
});
|
||||
});
|
||||
|
||||
test('checks in the code the language becomes, nothing for an empty '
|
||||
'text', () async {
|
||||
final fake = _FakeLanguageTool();
|
||||
final checking = GrammarChecking(languageTool: fake);
|
||||
|
||||
await checking.check(alice, 'Ein Satz.', language: 'de');
|
||||
await checking.check(alice, 'A sentence.');
|
||||
final empty = await checking.check(alice, ' \n');
|
||||
|
||||
expect(fake.checked, [('Ein Satz.', 'de-DE'), ('A sentence.', 'auto')]);
|
||||
expect(empty.matches, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses a language its LanguageTool lacks, before sending '
|
||||
'anything', () async {
|
||||
final fake = _FakeLanguageTool();
|
||||
final checking = GrammarChecking(languageTool: fake);
|
||||
|
||||
await expectLater(
|
||||
checking.check(alice, '안녕하세요.', language: 'ko'),
|
||||
throwsA(_refused(GrammarRefusal.unknownLanguage)),
|
||||
);
|
||||
expect(fake.checked, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses a text longer than one check takes', () async {
|
||||
final fake = _FakeLanguageTool();
|
||||
final checking = GrammarChecking(languageTool: fake);
|
||||
|
||||
await checking.check(alice, 'a' * GrammarChecking.maxText);
|
||||
await expectLater(
|
||||
checking.check(alice, 'a' * (GrammarChecking.maxText + 1)),
|
||||
throwsA(_refused(GrammarRefusal.tooLong)),
|
||||
);
|
||||
expect(fake.checked, hasLength(1));
|
||||
});
|
||||
|
||||
test('refuses the 61st check in a minute, of that person alone, and '
|
||||
'takes them again once the minute has passed', () async {
|
||||
final fake = _FakeLanguageTool();
|
||||
var now = DateTime.utc(2026, 10, 26, 12);
|
||||
await withClock(Clock(() => now), () async {
|
||||
final checking = GrammarChecking(languageTool: fake);
|
||||
for (var i = 0; i < GrammarChecking.perMinute; i++) {
|
||||
await checking.check(alice, 'Text $i.');
|
||||
now = now.add(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
checking.check(alice, 'One more.'),
|
||||
throwsA(_refused(GrammarRefusal.tooOften)),
|
||||
);
|
||||
await checking.check(bob, 'Bob’s first.');
|
||||
|
||||
now = now.add(const Duration(seconds: 31));
|
||||
await checking.check(alice, 'Later.');
|
||||
});
|
||||
expect(fake.checked.last.$1, 'Later.');
|
||||
});
|
||||
|
||||
test('says it is unavailable when LanguageTool fails, and looks for its '
|
||||
'languages again on the next check', () async {
|
||||
final fake = _FakeLanguageTool()..fails = true;
|
||||
final checking = GrammarChecking(languageTool: fake);
|
||||
await checking.look();
|
||||
expect(checking.languages, isNull);
|
||||
|
||||
await expectLater(
|
||||
checking.check(alice, 'A text.'),
|
||||
throwsA(_refused(GrammarRefusal.unavailable)),
|
||||
);
|
||||
|
||||
fake.fails = false;
|
||||
await checking.check(alice, 'A text.');
|
||||
expect(checking.languages, ['de', 'en', 'ja']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Matcher _refused(GrammarRefusal reason) => isA<GrammarRefusedException>()
|
||||
.having((refused) => refused.reason, 'reason', reason);
|
||||
|
||||
/// A LanguageTool that remembers what it was asked to check and finds
|
||||
/// nothing.
|
||||
class _FakeLanguageTool implements LanguageTool {
|
||||
List<String> codes = ['de-DE', 'en-US', 'ja-JP'];
|
||||
bool fails = false;
|
||||
final checked = <(String, String)>[];
|
||||
|
||||
@override
|
||||
Uri get address => Uri.parse('http://languagetool:8081/');
|
||||
|
||||
@override
|
||||
Future<List<String>> languages() async {
|
||||
if (fails) {
|
||||
throw const LanguageToolFailed('down');
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GrammarCheck> check(String text, String language) async {
|
||||
if (fails) {
|
||||
throw const LanguageToolFailed('down');
|
||||
}
|
||||
checked.add((text, language));
|
||||
return GrammarCheck(
|
||||
matches: const [],
|
||||
language: language == 'auto' ? 'en-US' : language,
|
||||
truncated: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue