feat: ship the interface in eight languages, with per-language plurals and mirroring

This commit is contained in:
randogoth 2026-10-11 14:54:43 +03:00
parent 6de6672ae3
commit a4a49f41d7
28 changed files with 1795 additions and 32 deletions

View file

@ -245,6 +245,50 @@ void main() {
expect(deps.browser.error.value, isNull);
});
testWidgets('choosing a language changes the interface in place',
(tester) async {
await pumpApp(tester);
await deps.browser.open(MewsUrls.settings);
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
// The language group sits below the fold.
Future<void> scrollTo(String label) async {
final target = find.textContaining(label);
for (var i = 0; i < 30 && target.evaluate().isEmpty; i++) {
await tester.drag(find.byType(ListView), const Offset(0, -200));
await tester.pumpAndSettle();
}
await tester.ensureVisible(target.first);
await tester.pumpAndSettle();
}
// Each language is named in its own words, so it can be found from an
// interface the reader cannot read.
await scrollTo('Deutsch');
final stepsBack = deps.browser.canGoBack;
await tapRun(tester, 'Deutsch');
expect(deps.settings.language.value, 'de');
// Applied in place, like the other reader choices.
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
expect(deps.browser.canGoBack, stepsBack);
expect(find.text('Einstellungen'), findsOneWidget);
expect(find.text('Settings'), findsNothing);
// Hebrew turns the whole page around.
await scrollTo('עברית');
await tapRun(tester, 'עברית');
expect(deps.settings.language.value, 'he');
expect(deps.browser.currentPage.value?.direction, TextDirection.rtl);
// Back to English.
await scrollTo('English');
await tapRun(tester, 'English');
expect(deps.settings.language.value, 'en-US');
expect(deps.browser.currentPage.value?.direction, TextDirection.ltr);
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('bare mews: and an unknown name resolve predictably',
(tester) async {
await pumpApp(tester);

162
app/test/locales_test.dart Normal file
View file

@ -0,0 +1,162 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/l10n/locales.dart';
import 'package:mews/l10n/strings.dart';
final _message = RegExp(r'^([a-z0-9-]+) = (.*)$', multiLine: true);
final _placeholder = RegExp(r'\{ \$([a-z]+) \}');
Map<String, String> _messagesOf(String tag) {
final source = File('locales/$tag/main.ftl').readAsStringSync();
return {
for (final m in _message.allMatches(source)) m.group(1)!: m.group(2)!,
};
}
void main() {
final english = _messagesOf(fallbackLocale);
test('every shipped language has a file declared as an asset', () {
final pubspec = File('pubspec.yaml').readAsStringSync();
for (final locale in appLocales) {
expect(File('locales/${locale.tag}/main.ftl').existsSync(), isTrue,
reason: '${locale.tag} has no messages');
expect(pubspec, contains('locales/${locale.tag}/main.ftl'),
reason: '${locale.tag} is not bundled as an asset');
}
});
test('every language says everything English says', () {
for (final locale in appLocales) {
if (locale.tag == fallbackLocale) continue;
final messages = _messagesOf(locale.tag);
for (final id in english.keys) {
expect(messages, contains(id),
reason: '${locale.tag} is missing $id');
expect(messages[id], isNotEmpty, reason: '${locale.tag}: $id is empty');
}
// The only messages a language may add are plural forms English has no
// use for, such as Russian's few or Hebrew's two.
for (final id in messages.keys.where((i) => !english.containsKey(i))) {
expect(id, matches(r'-(two|few)$'),
reason: '${locale.tag} defines an unknown $id');
expect(english, contains(id.replaceFirst(RegExp(r'-(two|few)$'), '-many')),
reason: '$id has no English counterpart to fall back on');
}
}
});
test('every language keeps the placeholders a message needs', () {
for (final locale in appLocales) {
if (locale.tag == fallbackLocale) continue;
final messages = _messagesOf(locale.tag);
for (final entry in english.entries) {
final wanted = _placeholder
.allMatches(entry.value)
.map((m) => m.group(1))
.toSet();
final got = _placeholder
.allMatches(messages[entry.key]!)
.map((m) => m.group(1))
.toSet();
// A dropped placeholder loses the number or name it carried.
expect(got, containsAll(wanted),
reason: '${locale.tag}: ${entry.key} drops a placeholder');
// An invented one renders as nothing. The exception is the count on a
// singular message: English writes "1 bookmark", but a language whose
// singular also covers 21 and 31 has to show the number.
final extra = got.difference(wanted);
expect(
extra.isEmpty ||
(extra.length == 1 &&
extra.first == 'count' &&
entry.key.endsWith('-one')),
isTrue,
reason: '${locale.tag}: ${entry.key} adds $extra');
}
}
});
test('a language naming a plural form defines every one it needs', () {
// The plural rules decide which suffix a count asks for; a language whose
// rules can produce a form it never defines would fall back to English.
for (final locale in appLocales) {
final messages = _messagesOf(locale.tag);
final stems = english.keys
.where((id) => id.endsWith('-many'))
.map((id) => id.substring(0, id.length - '-many'.length));
for (final stem in stems) {
for (var count = 1; count <= 200; count++) {
final suffix = pluralSuffix(locale.tag, count);
if (suffix == 'one' || suffix == 'many') continue;
expect(messages, contains('$stem-$suffix'),
reason: '${locale.tag} needs $stem-$suffix for $count');
}
}
}
});
group('choosing a language', () {
test('matches on the language alone, so a region still fits', () {
expect(matchLocale('de-AT'), 'de');
expect(matchLocale('pt-BR'), 'pt');
expect(matchLocale('en-GB'), 'en-US');
expect(matchLocale('en-US'), 'en-US');
expect(matchLocale('is'), isNull);
});
test('takes the first of the device languages it ships', () {
expect(resolveLocale(['is', 'fo', 'ru-RU']), 'ru');
expect(resolveLocale(['is', 'fo']), fallbackLocale);
expect(resolveLocale([]), fallbackLocale);
});
test('knows which languages mirror', () {
expect(isRightToLeft('he'), isTrue);
expect(isRightToLeft('en-US'), isFalse);
});
});
group('plural rules', () {
test('Russian separates two to four from the rest', () {
expect(pluralSuffix('ru', 1), 'one');
expect(pluralSuffix('ru', 21), 'one');
expect(pluralSuffix('ru', 11), 'many');
expect(pluralSuffix('ru', 2), 'few');
expect(pluralSuffix('ru', 24), 'few');
expect(pluralSuffix('ru', 12), 'many');
expect(pluralSuffix('ru', 5), 'many');
});
test('Hebrew has a form for two', () {
expect(pluralSuffix('he', 1), 'one');
expect(pluralSuffix('he', 2), 'two');
expect(pluralSuffix('he', 3), 'many');
});
test('French and Portuguese read one for a single thing', () {
expect(pluralSuffix('fr', 1), 'one');
expect(pluralSuffix('fr', 2), 'many');
expect(pluralSuffix('pt', 1), 'one');
});
test('Japanese has one form', () {
expect(pluralSuffix('ja', 1), 'many');
expect(pluralSuffix('ja', 7), 'many');
});
});
test('a message missing from a language falls back to English', () {
final strings = Strings()
..loadSource(File('locales/en-US/main.ftl').readAsStringSync())
..loadSource('nav-posts = Beiträge', tag: 'de');
expect(strings.t('nav-posts'), 'Beiträge');
// Not translated in this stub, so English stands in rather than an id.
expect(strings.t('nav-bookmarks'), 'Bookmarks');
expect(strings.t('no-such-message'), 'no-such-message');
expect(strings.lookup('no-such-message'), isNull);
});
}