Files
fanuzu/app/test/i18n_test.dart
T

245 lines
8.6 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:fancake/core/i18n/app_locale.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/settings_screen.dart';
import 'package:fancake/l10n/app_localizations.dart'
show lookupAppLocalizations;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../tool/gen_l10n_remote.dart';
Map<String, dynamic> _arb(String lang) =>
jsonDecode(File('lib/l10n/app_$lang.arb').readAsStringSync())
as Map<String, dynamic>;
Set<String> _keys(Map<String, dynamic> arb) =>
arb.keys.where((k) => !k.startsWith('@')).toSet();
Widget _app(LocaleController c, Widget home) => ListenableBuilder(
listenable: c,
builder: (_, _) => LocaleScope(
controller: c,
child: MaterialApp(
locale: c.locale,
supportedLocales: appLocales,
localizationsDelegates: [
RemoteL10nDelegate(c.remote),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: home,
),
),
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('지원 언어 (정책 원장 B-76)', () {
test('supportedLocales 가 정확히 ko · en · ja · zh-CN · es 다', () {
expect(appLocales.map(localeTag).toList(), [
'ko',
'en',
'ja',
'zh-CN',
'es',
]);
});
test('단말 언어로 기본값을 고르고 zh-TW·zh-Hant 는 zh-CN · 미지원 언어는 en 으로 폴백한다', () {
expect(resolveAppLocale(const [Locale('ja', 'JP')]), const Locale('ja'));
expect(
resolveAppLocale(const [Locale('zh', 'TW')]),
const Locale('zh', 'CN'),
);
expect(
resolveAppLocale(const [
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
]),
const Locale('zh', 'CN'),
);
expect(resolveAppLocale(const [Locale('fr', 'FR')]), const Locale('en'));
expect(
resolveAppLocale(const [Locale('fr'), Locale('es', 'MX')]),
const Locale('es'),
);
});
test('5개 언어 arb 가 같은 키를 전부 가진다(QA 8-2 문자열 누락 0)', () {
final ko = _keys(_arb('ko'));
for (final lang in const ['en', 'ja', 'zh', 'es']) {
final other = _keys(_arb(lang));
expect(ko.difference(other), isEmpty, reason: '$lang 누락');
expect(other.difference(ko), isEmpty, reason: '$lang 초과');
for (final k in other) {
expect(
(_arb(lang)[k] as String).trim(),
isNotEmpty,
reason: '$lang.$k',
);
}
}
});
test('원격 덮어쓰기 층이 arb 의 모든 키를 덮는다(생성물이 최신)', () {
final generated = File(
'lib/l10n/remote_app_localizations.dart',
).readAsStringSync();
final expected = generateRemoteL10n(_arb('ko'));
for (final key in _keys(_arb('ko'))) {
expect(expected, contains(' $key'));
expect(generated, contains(' $key'), reason: '재생성 필요: $key');
}
});
});
group('언어 전환 · 원격 문자열 (FR-I18N-01)', () {
testWidgets('설정의 언어 시트에서 English 를 고르면 화면 문자열이 바뀌고 기기에 남는다', (
tester,
) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
String? saved;
final c = LocaleController(
initial: fallbackLocale,
onSaved: (tag) async => saved = tag,
);
await tester.pumpWidget(
_app(c, SettingsScreen(repository: SampleMyRepository())),
);
await tester.pumpAndSettle();
expect(find.text('설정'), findsOneWidget);
await tester.scrollUntilVisible(find.text('한국어'), 200);
await tester.tap(find.text('한국어'));
await tester.pumpAndSettle();
await tester.tap(find.text('English'));
await tester.tap(find.text('선택'));
await tester.pumpAndSettle();
expect(c.locale, const Locale('en'));
expect(find.text('Settings'), findsOneWidget);
expect(saved, 'en');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(LocaleController.prefsKey), 'en');
});
testWidgets('원격 문서의 키는 번들 문자열을 덮고, 없는 키는 번들을 쓴다', (tester) async {
final remote = StreamController<Map<String, Object?>>();
addTearDown(remote.close);
final c = LocaleController(
initial: const Locale('ja'),
remoteSource: (tag) {
expect(tag, 'ja');
return remote.stream;
},
);
await tester.pumpWidget(
_app(
c,
Builder(
builder: (context) => Column(
children: [
Text(context.l10n.settingsTitle),
Text(context.l10n.commonNotYet('X')),
Text(context.l10n.commonCancel),
],
),
),
),
);
expect(find.text('設定'), findsOneWidget);
remote.add({
'settingsTitle': '設定(更新)',
'commonNotYet': '{where} まもなく',
'ignored': 3,
});
await tester.pumpAndSettle();
expect(find.text('設定(更新)'), findsOneWidget);
expect(find.text('X まもなく'), findsOneWidget);
expect(find.text('キャンセル'), findsOneWidget);
});
test('원격 문자열은 인자 값 안의 자리표시자를 다시 치환하지 않는다', () {
final t = RemoteL10nDelegate(const {
'linkTitle': '{name} 연동',
'myShareText': '{nickname}: {level}',
});
return t.load(const Locale('ko')).then((l10n) {
expect(l10n.linkTitle('{name}'), '{name} 연동');
expect(l10n.myShareText('{level}', 'A', '1'), '{level}: A');
});
});
test('언어를 복원하면 이전 언어의 원격 문자열을 버린다', () async {
final remote = StreamController<Map<String, Object?>>.broadcast();
addTearDown(remote.close);
final c = LocaleController(
initial: const Locale('ja'),
remoteSource: (_) => remote.stream,
);
remote.add({'settingsTitle': '設定(更新)'});
await Future<void>.delayed(Duration.zero);
expect(c.remote, isNotEmpty);
SharedPreferences.setMockInitialValues({LocaleController.prefsKey: 'es'});
await c.restore();
expect(c.locale, const Locale('es'));
expect(c.remote, isEmpty);
});
test('로그인 때 이 기기에서 고른 언어가 없으면 프로필 언어를 따른다', () async {
final c = LocaleController(initial: const Locale('en'));
await c.adoptProfile('ja');
expect(c.locale, const Locale('ja'));
});
test('이 기기에서 고른 언어가 있으면 프로필 언어보다 우선한다', () async {
SharedPreferences.setMockInitialValues({LocaleController.prefsKey: 'es'});
final c = LocaleController(initial: const Locale('es'));
await c.adoptProfile('ja');
expect(c.locale, const Locale('es'));
});
test('저장된 언어를 복원한다', () async {
SharedPreferences.setMockInitialValues({
LocaleController.prefsKey: 'zh-CN',
});
final c = LocaleController(initial: fallbackLocale);
await c.restore();
expect(c.locale, const Locale('zh', 'CN'));
});
test('대리자가 없는 트리는 ko 로 폴백한다', () {
expect(koL10n.settingsTitle, '설정');
expect(
lookupAppLocalizations(const Locale('zh', 'CN')).settingsTitle,
'设置',
);
});
});
// 10/4 실기기 — 「발주사」 · 「09-10 회차에 연결」 · 오탈자 「맴버」가 사용자 화면에 떴다.
test('사용자 문구에 내부 표현 · 일정 · 오탈자가 없다', () {
const banned = ['발주사', '発注元', 'client key', 'MOCK', '회차에 연결', '09-10', '2026-', 'Q-QC-', '맴버', '구현 확인물'];
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb')) continue;
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
for (final MapEntry(:key, :value) in arb.entries) {
if (key.startsWith('@') || value is! String) continue;
for (final word in banned) {
expect(value.contains(word), isFalse, reason: '${file.path} $key: $value');
}
}
}
});
}