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 _arb(String lang) => jsonDecode(File('lib/l10n/app_$lang.arb').readAsStringSync()) as Map; Set _keys(Map 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>(); 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>.broadcast(); addTearDown(remote.close); final c = LocaleController( initial: const Locale('ja'), remoteSource: (_) => remote.stream, ); remote.add({'settingsTitle': '設定(更新)'}); await Future.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()) { if (!file.path.endsWith('.arb')) continue; final arb = jsonDecode(file.readAsStringSync()) as Map; 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'); } } } }); }