import 'dart:async'; import 'package:fancake/app.dart'; import 'package:fancake/core/data/repositories.dart'; import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/core/i18n/app_locale.dart'; import 'package:fancake/features/planet/planet_data.dart' show countryName; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/auth/login_screen.dart'; import 'package:fancake/features/auth/onboarding_data.dart'; import 'package:fancake/features/auth/onboarding_screens.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 온보딩 6단계(10/3 통합회신 1항) · 「로그인」 버튼 신규 계정의 약관 우회 차단(E-05). void main() { setUp(() { FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '온보딩 테스트'); SharedPreferences.setMockInitialValues({}); }); Future<(AuthRepository, SampleOnboardingRepository)> boot( WidgetTester tester, { required bool needs, bool intro = false, Duration checkDelay = Duration.zero, }) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); final repos = Repositories(live: false); final auth = AuthRepository( providers: [_Provider()], releaseMode: true, needsOnboarding: (_) => Future.delayed(checkDelay, () => needs), introPending: intro, ); addTearDown(auth.dispose); await tester.pumpWidget(FancakeApp(auth: auth, repositories: repos)); await tester.pumpAndSettle(); return (auth, repos.onboarding as SampleOnboardingRepository); } testWidgets('「로그인」으로 들어온 신규 계정도 약관부터 — 셸로 가지 않는다', (tester) async { final (auth, _) = await boot(tester, needs: true); await auth.signIn(SocialProvider.google); await tester.pumpAndSettle(); expect(auth.onboarding, OnboardingGate.required); expect(find.byType(NavigationBar), findsNothing); expect(find.text('개인정보 수집·이용 및 처리방침 동의 (필수)'), findsOneWidget); }); testWidgets('약관 동의가 있는 계정은 바로 셸', (tester) async { final (auth, _) = await boot(tester, needs: false); await auth.signIn(SocialProvider.google); await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsOneWidget); }); testWidgets('동의한 계정은 셸로 가는 동안 약관 화면이 한 프레임도 그려지지 않는다 — 10/6 콜드 스타트 깜빡임', ( tester, ) async { // 서버 판정이 몇 프레임 걸리는 실기기처럼 늦춘다. final (auth, _) = await boot( tester, needs: false, checkDelay: const Duration(milliseconds: 200), ); unawaited(auth.signIn(SocialProvider.google)); for (var i = 0; i < 60; i++) { await tester.pump(const Duration(milliseconds: 16)); expect(find.byType(OnboardingFlow), findsNothing); } await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsOneWidget); }); testWidgets('약관 → 프로필 → 관심 아티스트 → 행성 가입 → 권한 → 가입 완료 → 셸', (tester) async { final (auth, repo) = await boot(tester, needs: true); await auth.signIn(SocialProvider.google); await tester.pumpAndSettle(); // ② 약관 — 필수만 동의(마케팅 미동의). for (final label in [ '서비스 이용약관 (필수)', '개인정보 수집·이용 및 처리방침 동의 (필수)', '위치기반 서비스 이용약관 (필수)', '만 14세 이상입니다. (필수)', ]) { await tester.tap(find.text(label)); } await tester.pump(); await tester.tap(find.text('완료')); await tester.pumpAndSettle(); expect(repo.agreed, {'service', 'privacy', 'location', 'age'}); // ③ 프로필 await tester.enterText(find.byType(TextField).first, '우주팬'); await tester.enterText(find.byType(TextField).last, 'ABCDEFGHJK'); await tester.pump(); await tester.tap(find.text('다음')); await tester.pumpAndSettle(); expect(repo.profile?.nickname, '우주팬'); expect(repo.profile?.country, isNotEmpty); expect(repo.referral, 'ABCDEFGHJK'); // ④ 관심 아티스트 — 최소 1개 전에는 다음이 잠겨 있다. final next = find.widgetWithText(FilledButton, '다음'); expect(tester.widget(next).onPressed, isNull); await tester.tap(find.text('NewJeans')); await tester.tap(find.text('BTS')); await tester.pump(); await tester.tap(next); await tester.pumpAndSettle(); // ⑤ 행성 가입 — 고른 순서(첫 번째가 대표). await tester.tap(find.text('선택한 행성 가입하기')); await tester.pumpAndSettle(); expect(repo.joined, ['newjeans', 'bts']); // ⑥ 권한 안내 → 가입 완료(182:29344) → 셸 expect(find.text('앱 권한 안내'), findsOneWidget); await tester.tap(find.text('계속')); await tester.pumpAndSettle(); expect(find.text('회원가입이 완료되었습니다'), findsOneWidget); await tester.tap(find.text('시작')); await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsOneWidget); expect( (await SharedPreferences.getInstance()).getBool('coach.pending'), isNotNull, ); }); testWidgets('프로필 국가 — 사본 국가 리스트 모달(178:23489) 대로 국가명 · 단수 선택 · 「선택」 후 적용', ( tester, ) async { final (auth, repo) = await boot(tester, needs: true); await auth.signIn(SocialProvider.google); await tester.pumpAndSettle(); for (final label in [ '서비스 이용약관 (필수)', '개인정보 수집·이용 및 처리방침 동의 (필수)', '위치기반 서비스 이용약관 (필수)', '만 14세 이상입니다. (필수)', ]) { await tester.tap(find.text(label)); } await tester.pump(); await tester.tap(find.text('완료')); await tester.pumpAndSettle(); await tester.tap( find.ancestor(of: find.text('거주 국가/지역'), matching: find.byType(InkWell)), ); await tester.pumpAndSettle(); expect(find.text('거주 국가/지역 선택'), findsOneWidget); await tester.scrollUntilVisible( find.text('대한민국'), 300, scrollable: find.byType(Scrollable).last, ); await tester.tap(find.text('대한민국')); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '선택')); await tester.pumpAndSettle(); expect(find.textContaining('대한민국'), findsOneWidget); await tester.enterText(find.byType(TextField).first, '우주팬'); await tester.pump(); await tester.tap(find.text('다음')); await tester.pumpAndSettle(); expect(repo.profile?.country, 'KR'); }); testWidgets('프로필 뒤로가기(511:86825) — 약관으로 돌아가 동의가 체크된 채 보이고 다시 기록하지 않는다', ( tester, ) async { final (auth, repo) = await boot(tester, needs: true); await auth.signIn(SocialProvider.google); await tester.pumpAndSettle(); for (final label in [ '서비스 이용약관 (필수)', '개인정보 수집·이용 및 처리방침 동의 (필수)', '위치기반 서비스 이용약관 (필수)', '만 14세 이상입니다. (필수)', ]) { await tester.tap(find.text(label)); } await tester.pump(); await tester.tap(find.text('완료')); await tester.pumpAndSettle(); final first = repo.agreed; await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); // 필수 4항이 이미 체크돼 있어 「완료」가 바로 켜져 있다. final done = find.widgetWithText(FilledButton, '완료'); expect(tester.widget(done).onPressed, isNotNull); await tester.tap(done); await tester.pumpAndSettle(); expect(identical(repo.agreed, first), isTrue); expect(find.byType(BackButton), findsOneWidget); // ③ 프로필로 돌아왔다. }); testWidgets('추천인 코드(C-119) — 귀속 뒤 돌아와도 다시 부르지 않고, 다른 코드는 511:86845 문구로 거절', ( tester, ) async { final (auth, repo) = await boot(tester, needs: true); await auth.signIn(SocialProvider.google); await tester.pumpAndSettle(); for (final label in [ '서비스 이용약관 (필수)', '개인정보 수집·이용 및 처리방침 동의 (필수)', '위치기반 서비스 이용약관 (필수)', '만 14세 이상입니다. (필수)', ]) { await tester.tap(find.text(label)); } await tester.pump(); await tester.tap(find.text('완료')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField).first, '우주팬'); await tester.enterText(find.byType(TextField).last, 'abcdefghjk'); await tester.pump(); await tester.tap(find.text('다음')); await tester.pumpAndSettle(); expect(repo.referral, 'ABCDEFGHJK'); // ④ → ③ : 귀속한 코드가 채워져 있고 그대로 「다음」이면 통과(두 번째 호출 없음). await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); expect(find.text('ABCDEFGHJK'), findsOneWidget); // 목 저장소는 저장한 닉네임을 돌려주지 않는다 — 다시 넣는다. await tester.enterText(find.byType(TextField).first, '우주팬'); await tester.pump(); await tester.tap(find.text('다음')); await tester.pumpAndSettle(); expect(find.text('NewJeans'), findsOneWidget); // ④ 로 넘어갔다. // 다른 코드로 바꾸면 서버가 1인 1회로 거절 → Figma 오류 문구, 단계는 그대로. await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField).first, '우주팬'); await tester.enterText(find.byType(TextField).last, 'ZZZZZZZZZZ'); await tester.pump(); await tester.tap(find.text('다음')); await tester.pumpAndSettle(); expect(find.text('추천인 코드를 확인해 주세요.'), findsOneWidget); expect(repo.referral, 'ABCDEFGHJK'); }); testWidgets('가입 모드에서 받은 약관은 다시 묻지 않고 기록만 한다', (tester) async { final (auth, repo) = await boot(tester, needs: true); auth.pendingConsent = { 'service', 'privacy', 'location', 'age', 'marketing', }; await auth.signIn(SocialProvider.google, signUp: true); await tester.pumpAndSettle(); expect(repo.agreed, contains('marketing')); expect(find.text('거주 국가/지역'), findsOneWidget); // ③ 프로필 expect(find.text('회원가입'), findsOneWidget); // 511:86825 머리줄 가운데 타이틀 }); test('국가명은 OS 이름 · OS 정렬 순서 — 이름 없는 나라도 코드로 보이지 않는다', () async { TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel('kr.co.fanuzu.app/locale'); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; String? asked; messenger.setMockMethodCallHandler(channel, (call) async { asked = (call.arguments as Map)['locale'] as String; return [ ['GH', '가나'], ['AD', '안도라'], ['KR', '대한민국'], ]; }); addTearDown(() { messenger.setMockMethodCallHandler(channel, null); platformCountryNames = const {}; }); await loadCountryNames(const Locale('ko')); expect(asked, 'ko'); expect(countryName('AD'), '안도라'); expect(sortedCountries().take(3), ['GH', 'AD', 'KR']); expect(sortedCountries().length, isoCountries.length); }); testWidgets('첫 실행은 소개 3장 — 건너뛰기를 눌러도 로그인 화면', (tester) async { final (auth, _) = await boot(tester, needs: false, intro: true); expect(find.text('우리 팬덤의 행성을 키워요'), findsOneWidget); await tester.tap(find.text('건너뛰기')); await tester.pumpAndSettle(); expect(auth.introPending, isFalse); expect(find.byKey(loginLogoKey), findsOneWidget); }); } final class _Provider implements SocialAuthProvider { @override AuthProviderKind get provider => SocialProvider.google; @override bool get isLive => true; @override List get missingConfig => const []; @override Future signIn() async => const AuthSucceeded( AuthAccount(uid: 'onb-user', provider: SocialProvider.google), ); @override Future signOut() async {} }