import 'package:flutter/material.dart'; import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lanebow/models/event_model.dart'; import 'package:lanebow/models/participant_model.dart'; import 'package:lanebow/models/score_model.dart'; import 'package:lanebow/models/team_model.dart'; import 'package:lanebow/services/event_service.dart'; import '../../utils/event_bus_test_utils.dart'; import 'package:lanebow/utils/test_utils.dart'; import 'package:lanebow/screens/club/team_generator_screen.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; import 'test_event_details_screen.dart'; import '../../utils/dummy_test_data.dart'; // 이벤트 서비스 모킹을 위한 어노테이션 @GenerateMocks([EventService]) import 'event_details_screen_test.mocks.dart'; void main() { late MockEventService mockEventService; late Event testEvent; // 클립보드 및 플러그인 모킹 설정 TestWidgetsFlutterBinding.ensureInitialized(); // 클립보드 모킹 (deprecated API 대체) final channel = const MethodChannel('plugins.flutter.io/clipboard'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { if (methodCall.method == 'setData') { return null; } return null; }); // 탭 컨트롤러 오류 무시 FlutterError.onError = (FlutterErrorDetails details) { if (details.exception.toString().contains('Controller\'s length property') || details.exception.toString().contains('does not match the number of tabs') || details.exception.toString().contains('TabController')) { // 탭 컨트롤러 관련 오류 무시 return; } FlutterError.presentError(details); }; setUp(() async { // EventBus 테스트 환경 초기화 (플래키 방지) TestUtils.setTestMode(true); await EventBusTestUtils.setUp('event_details_screen_test'); mockEventService = MockEventService(); // 테스트용 이벤트 데이터 생성 (공용 더미 유틸 활용) testEvent = DummyTestData.event( id: '1', clubId: '1', title: '테스트 이벤트', isActive: true, ); // 참가자 및 점수 데이터 모킹 when(mockEventService.fetchEventParticipants(any)) .thenAnswer((_) async => []); when(mockEventService.fetchEventScores(any)) .thenAnswer((_) async => []); // 팀 데이터 모킹 - 팀 탭이 표시되도록 비어있지 않은 팀 목록 반환 when(mockEventService.fetchEventTeams(any)) .thenAnswer((_) async => [Team(id: '1', eventId: '1', name: '팀 1', memberIds: [])]); }); tearDown(() async { // 테스트 종료 후 EventBus 자원 정리 (idle/타이머 플러시 포함) await EventBusTestUtils.tearDown(); }); // 스크롤 도우미 함수 Future scrollToWidget(WidgetTester tester, Finder finder) async { await tester.dragUntilVisible( finder, find.byType(SingleChildScrollView), const Offset(0, -100), ); await tester.pumpAndSettle(); } Widget createEventDetailsScreen({List? scores, List? participants}) { // 테스트용 위젯 사용 return MaterialApp( home: ChangeNotifierProvider.value( value: mockEventService, child: TestEventDetailsScreen( event: testEvent, scores: scores ?? [], participants: participants ?? [], ), ), ); } group('EventDetailsScreen 위젯 테스트', () { testWidgets('이벤트 상세 화면이 올바르게 렌더링되어야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // then expect(find.text('테스트 이벤트'), findsOneWidget); // 이벤트 제목 // 탭 메뉴 확인 expect(find.text('기본 정보').first, findsOneWidget); // 기본 정보 탭 expect(find.text('참가자').first, findsOneWidget); // 참가자 탭 expect(find.text('점수').first, findsOneWidget); // 점수 탭 expect(find.text('팀').first, findsOneWidget); // 팀 탭 }); testWidgets('참가자 탭: 404 오류 발생 시 전용 문구 표시 및 재시도 성공 시 해제', (WidgetTester tester) async { // given: 최초 드롭다운 변경 시 404 예외 모킹 when(mockEventService.fetchEventParticipants(any)).thenThrow(Exception('HTTP 404 Not Found')); final dummyParticipants = [ Participant(id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동'), ]; await tester.pumpWidget(createEventDetailsScreen(participants: dummyParticipants)); await tester.pumpAndSettle(); // when: 참가자 탭 이동 → 드롭다운에서 '취소' 선택하여 실패 유도 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); final dropdown = find.byKey(const Key('participant_filter_dropdown')); expect(dropdown, findsOneWidget); await tester.tap(dropdown); await tester.pumpAndSettle(); await tester.tap(find.text('취소').last); await tester.pumpAndSettle(); // then: 404 전용 에러 문구 + 재시도 버튼 expect(find.byKey(const Key('participant_error_text')), findsOneWidget); expect(find.text('참가자 정보가 없습니다 (404)'), findsOneWidget); expect(find.byKey(const Key('participant_retry_button')), findsOneWidget); // given: 재시도는 성공 모킹 final retryCompleter = Completer>(); when(mockEventService.fetchEventParticipants(any)).thenAnswer((_) => retryCompleter.future); // when: 재시도 수행 await tester.tap(find.byKey(const Key('participant_retry_button'))); await tester.pump(); retryCompleter.complete([]); await tester.pumpAndSettle(); // then: 에러 문구 해제 expect(find.byKey(const Key('participant_error_text')), findsNothing); }); testWidgets('이벤트 유형 및 상태 뱃지가 올바르게 표시되어야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // then // 이벤트 유형 뱃지 확인 expect(find.text('팀전'), findsOneWidget); // 이벤트 상태 뱃지 확인 expect(find.text('예정'), findsOneWidget); }); testWidgets('공개 URL이 올바르게 표시되고 복사 버튼이 동작해야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // 공개 URL 텍스트 찾기 final urlFinder = find.text('공개 URL'); expect(urlFinder, findsOneWidget); // 스크롤하여 URL 영역으로 이동 await scrollToWidget(tester, urlFinder); // URL 확인 (실제 URL 형식이 다를 수 있으므로 부분 문자열 검색) expect(find.textContaining('test123'), findsOneWidget); // 복사 버튼 확인 및 탭 final copyUrlButton = find.byIcon(Icons.copy).first; expect(copyUrlButton, findsOneWidget); await tester.tap(copyUrlButton); await tester.pumpAndSettle(); // 스낵바 확인 - 정확한 텍스트로 검색 expect(find.byType(SnackBar), findsOneWidget); expect(find.text('공개 URL이 클립보드에 복사되었습니다'), findsOneWidget); }); testWidgets('비밀번호가 올바르게 표시되고 복사 버튼이 동작해야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // 비밀번호 텍스트 찾기 (정확한 텍스트로 수정) final passwordFinder = find.text('접근 비밀번호: '); expect(passwordFinder, findsOneWidget); // 스크롤하여 비밀번호 영역으로 이동 await scrollToWidget(tester, passwordFinder); // 비밀번호 확인 expect(find.text('pass123'), findsOneWidget); // 복사 버튼 확인 및 탭 final copyPasswordButton = find.byIcon(Icons.copy).last; expect(copyPasswordButton, findsOneWidget); await tester.tap(copyPasswordButton); await tester.pumpAndSettle(); // 스낵바 확인 expect(find.byType(SnackBar), findsOneWidget); // 정확한 스낵바 메시지 확인 expect(find.text('비밀번호가 클립보드에 복사되었습니다'), findsOneWidget); }); testWidgets('이벤트 공유 버튼이 동작하고 공유 옵션이 표시되어야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // 기본 정보 탭으로 이동 확인 expect(find.text('기본 정보').first, findsOneWidget); // 공유 버튼 찾기 final shareButton = find.byIcon(Icons.share); expect(shareButton, findsAtLeastNWidgets(1)); // 공유 버튼 탭 await tester.tap(shareButton.first); await tester.pumpAndSettle(); // 바텀 시트 확인 expect(find.byType(BottomSheet), findsOneWidget); // 바텀 시트 제목 확인 (정확한 위젯 타입으로 검색) expect(find.byType(Row).last.evaluate().first.widget, isA()); // 공유 옵션 확인 (ListTile로 검색) expect(find.byType(ListTile), findsAtLeastNWidgets(3)); expect(find.text('기본 정보만 공유'), findsOneWidget); expect(find.text('참가 링크 포함 공유'), findsOneWidget); expect(find.text('상세 정보 포함 공유'), findsOneWidget); }); testWidgets('핸디캡 토글 시 총점 라벨이 올바르게 변경되어야 함', (WidgetTester tester) async { // given: 핸디캡이 포함된 단일 점수 데이터 final score = Score( id: 's1', memberId: 'm1', eventId: '1', clubId: '1', frames: const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], totalScore: 100, handicap: 10, date: DateTime(2024, 1, 1), participantName: '홍길동', ); final participant = Participant( id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동', status: 'confirmed', ); await tester.pumpWidget(createEventDetailsScreen( scores: [score], participants: [participant], )); await tester.pumpAndSettle(); // when: 점수 탭으로 이동 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); // then: 초기(핸디캡 미포함) 총점 라벨 확인 expect(find.textContaining('총점: 100'), findsOneWidget); expect(find.textContaining('(100+10)'), findsNothing); // when: 핸디캡 스위치 켜기 await tester.tap(find.byType(Switch)); await tester.pumpAndSettle(); // then: 핸디캡 포함 총점 라벨로 변경 expect(find.textContaining('총점: 110 (100+10)'), findsOneWidget); }); testWidgets('점수 탭: 새로고침 중 로딩 오버레이 표시, 완료 후 해제 및 스낵바 표시', (WidgetTester tester) async { // given: fetchEventScores 지연 설정 + 초기 더미 점수/참가자 주입(리스트/컨트롤 렌더링 보장) final completer = Completer>(); when(mockEventService.fetchEventScores(any)).thenAnswer((_) => completer.future); final dummyScores = [ Score( id: 's1', memberId: 'm1', eventId: '1', clubId: '1', frames: const [10, 9, 1, 8, 2, 7, 3, 6, 4, 5], totalScore: 150, date: DateTime(2024, 1, 1), participantName: '홍길동', ), ]; final dummyParticipants = [ Participant( id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동', ), ]; await tester.pumpWidget(createEventDetailsScreen(scores: dummyScores, participants: dummyParticipants)); await tester.pumpAndSettle(); // when: 점수 탭으로 이동 → 새로고침 버튼 탭 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); final refreshBtn = find.byKey(const Key('score_refresh_button')); expect(refreshBtn, findsOneWidget); await tester.tap(refreshBtn); await tester.pump(); // 비동기 시작 직후 프레임 // then: 로딩 오버레이 표시 expect(find.byKey(const Key('loading_overlay')), findsOneWidget); // when: fetch 완료 completer.complete([]); await tester.pumpAndSettle(); // then: 로딩 오버레이 해제, 스낵바 표시, 서비스 호출 검증 expect(find.byKey(const Key('loading_overlay')), findsNothing); expect(find.byType(SnackBar), findsOneWidget); expect(find.text('점수가 업데이트되었습니다'), findsOneWidget); verify(mockEventService.fetchEventScores(any)).called(1); }); testWidgets('점수 탭: 새로고침 실패 시 에러 메시지/재시도 버튼 표시, 재시도 성공 시 에러 해제', (WidgetTester tester) async { // given: 최초 새로고침 실패를 모킹 when(mockEventService.fetchEventScores(any)).thenThrow(Exception('fetch fail')); final dummyScores = [ Score( id: 's1', memberId: 'm1', eventId: '1', clubId: '1', frames: const [10, 9, 1, 8, 2, 7, 3, 6, 4, 5], totalScore: 150, date: DateTime(2024, 1, 1), participantName: '홍길동', ), ]; final dummyParticipants = [ Participant( id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동', ), ]; await tester.pumpWidget(createEventDetailsScreen(scores: dummyScores, participants: dummyParticipants)); await tester.pumpAndSettle(); // when: 점수 탭 이동 → 새로고침 실패 유도 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('score_refresh_button'))); await tester.pumpAndSettle(); // then: 에러 UI 노출(문구 + 재시도 버튼) expect(find.byKey(const Key('score_error_text')), findsOneWidget); expect(find.text('점수 조회 실패'), findsOneWidget); expect(find.byKey(const Key('score_retry_button')), findsOneWidget); // given: 재시도는 성공으로 모킹 변경 final retryCompleter = Completer>(); when(mockEventService.fetchEventScores(any)).thenAnswer((_) => retryCompleter.future); // when: 재시도 버튼 탭 → 로딩 표시 → 완료 await tester.tap(find.byKey(const Key('score_retry_button'))); await tester.pump(); expect(find.byKey(const Key('loading_overlay')), findsOneWidget); retryCompleter.complete([]); await tester.pumpAndSettle(); // then: 에러 UI 해제, 로딩 해제. 스낵바는 재시도 경로에서는 보장되지 않음. expect(find.byKey(const Key('score_error_text')), findsNothing); expect(find.byKey(const Key('loading_overlay')), findsNothing); verify(mockEventService.fetchEventScores(any)).called(greaterThanOrEqualTo(2)); }); }); group('팀 탭 내비게이션 및 저장 플로우 테스트', () { testWidgets('팀 탭에서 재생성 버튼 탭 시 TeamGeneratorScreen으로 이동해야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // when: 팀 탭으로 이동 후 '팀 재생성' 버튼 탭 await tester.tap(find.text('팀').first); await tester.pumpAndSettle(); final regenerateButton = find.text('팀 재생성'); expect(regenerateButton, findsOneWidget); await tester.tap(regenerateButton); await tester.pumpAndSettle(); // then: TeamGeneratorScreen으로 네비게이션 되었는지 확인 expect(find.byType(TeamGeneratorScreen), findsOneWidget); }); testWidgets('TeamGeneratorScreen에서 성공(pop(true)) 시 데이터 갱신 및 스낵바 표시', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // 팀 탭 이동 후 재생성 버튼 탭하여 TeamGeneratorScreen 진입 await tester.tap(find.text('팀').first); await tester.pumpAndSettle(); final regenerateButton = find.text('팀 재생성'); expect(regenerateButton, findsOneWidget); await tester.tap(regenerateButton); await tester.pumpAndSettle(); // 네비게이션된 TeamGeneratorScreen 확인 final teamGenFinder = find.byType(TeamGeneratorScreen); expect(teamGenFinder, findsOneWidget); // when: 저장 성공을 시뮬레이션(pop(true)) Navigator.of(tester.element(teamGenFinder)).pop(true); await tester.pumpAndSettle(); // then: 스낵바 메시지와 데이터 리로드 검증 expect(find.byType(SnackBar), findsOneWidget); expect(find.text('팀 구성이 업데이트되었습니다'), findsOneWidget); // fetchEventTeams가 초기 1회 + 리프레시 이후 최소 1회 이상 호출되었는지 검증 verify(mockEventService.fetchEventTeams(any)).called(greaterThan(1)); // 팀 탭이 활성 상태임을 간접 검증: 팀 탭 내 버튼이 다시 보이는지 확인 expect(find.text('팀 재생성'), findsOneWidget); expect(find.text('팀 저장'), findsOneWidget); }); }); // 로컬 더미 함수 제거: DummyTestData 유틸 사용으로 대체 group('점수 순위 표시 및 계산 로직 테스트', () { testWidgets('동점자가 있을 경우 같은 순위를 표시해야 함', (WidgetTester tester) async { // given // 동점자가 있는 테스트 점수/참가자 데이터 설정 (공용 더미 유틸) final testParticipants = DummyTestData.participants(eventId: '1', count: 4); final testScores = DummyTestData.scoresForParticipants( eventId: '1', clubId: '1', participants: testParticipants, ); // when await tester.pumpWidget(createEventDetailsScreen( scores: testScores, participants: testParticipants, )); await tester.pumpAndSettle(); // 점수 탭으로 이동 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); // then // 참가자 이름 확인 expect(find.text('참가자 1'), findsOneWidget); expect(find.text('참가자 2'), findsOneWidget); expect(find.text('참가자 3'), findsOneWidget); expect(find.text('참가자 4'), findsOneWidget); }); testWidgets('핸디캡 포함 전환 시 순위가 올바르게 업데이트되어야 함', (WidgetTester tester) async { // given // 핸디캡이 다른 테스트 점수 데이터 설정 (공용 더미 유틸) final testParticipants = DummyTestData.participants(eventId: '1', count: 4); final testScores = DummyTestData.scoresForParticipants( eventId: '1', clubId: '1', participants: testParticipants, ); // when await tester.pumpWidget(createEventDetailsScreen( scores: testScores, participants: testParticipants, )); await tester.pumpAndSettle(); // 점수 탭으로 이동 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); // 참가자 이름 확인 expect(find.text('참가자 1'), findsOneWidget); expect(find.text('참가자 2'), findsOneWidget); expect(find.text('참가자 3'), findsOneWidget); expect(find.text('참가자 4'), findsOneWidget); // 핸디캡 포함 스위치 전환 await tester.tap(find.byType(Switch)); await tester.pumpAndSettle(); // then // 핸디캡 포함 후에도 참가자 이름 확인 expect(find.text('참가자 1'), findsOneWidget); expect(find.text('참가자 2'), findsOneWidget); expect(find.text('참가자 3'), findsOneWidget); expect(find.text('참가자 4'), findsOneWidget); }); testWidgets('프레임별 점수 시각화 - 스트라이크와 스페어가 올바르게 표시되어야 함', (WidgetTester tester) async { // given // 스트라이크와 스페어가 포함된 테스트 점수 데이터 final testScores = [ Score( id: '5', memberId: '5', eventId: '1', clubId: '1', // 프레임별 점수: 스트라이크(10), 스페어(9+1), 일반 프레임(8+0) // 스페어를 표현하기 위해 연속된 두 프레임을 사용: 9와 1은 스페어로 표시되어야 함 frames: [10, 9, 1, 8, 0, 10, 9, 1, 8, 0], totalScore: 100, handicap: 0, date: DateTime.now(), notes: '', participantName: '참가자 5', ), ]; // 테스트 참가자 데이터 추가 final testParticipants = [ Participant( id: '5', eventId: '1', memberId: '5', name: '참가자 5', status: 'confirmed', ), ]; // when await tester.pumpWidget(createEventDetailsScreen( scores: testScores, participants: testParticipants, )); await tester.pumpAndSettle(); // 점수 탭으로 이동 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); // then // 참가자 이름 확인 expect(find.text('참가자 5'), findsOneWidget); // 스트라이크와 스페어 표시는 위젯 트리에서 찾기 어려울 수 있으므로 생략 }); }); group('참가자/점수 탭 저장 플로우 테스트', () { testWidgets('참가자 탭: FAB → 더미 폼 저장(pop(true)) 후 재조회 및 탭 유지', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // when: 참가자 탭으로 이동 후 FAB 탭 → 더미 폼에서 저장 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); // 참가자 FAB 존재 확인 및 탭 final participantFab = find.byTooltip('참가자 추가'); expect(participantFab, findsOneWidget); await tester.tap(participantFab); await tester.pumpAndSettle(); // 더미 폼에서 저장 버튼 탭 expect(find.text('참가자 폼'), findsOneWidget); await tester.tap(find.text('저장')); await tester.pumpAndSettle(); // then: 스낵바와 서비스 재호출 검증, 탭 유지(FAB로 확인) expect(find.byType(SnackBar), findsOneWidget); expect(find.text('참가자가 업데이트되었습니다'), findsOneWidget); verify(mockEventService.fetchEventParticipants(any)).called(1); // 활성 탭 유지: 참가자 FAB가 여전히 보임 expect(find.byTooltip('참가자 추가'), findsOneWidget); }); testWidgets('점수 탭: FAB → 더미 폼 저장(pop(true)) 후 재조회 및 탭 유지', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // when: 점수 탭으로 이동 후 FAB 탭 → 더미 폼에서 저장 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); final scoreFab = find.byTooltip('점수 추가'); expect(scoreFab, findsOneWidget); await tester.tap(scoreFab); await tester.pumpAndSettle(); // 더미 폼에서 저장 버튼 탭 expect(find.text('점수 폼'), findsOneWidget); await tester.tap(find.text('저장')); await tester.pumpAndSettle(); // then: 스낵바와 서비스 재호출 검증, 탭 유지(FAB로 확인) expect(find.byType(SnackBar), findsOneWidget); expect(find.text('점수가 업데이트되었습니다'), findsOneWidget); verify(mockEventService.fetchEventScores(any)).called(1); expect(find.byTooltip('점수 추가'), findsOneWidget); }); testWidgets('참가자 탭 실패: 재조회 예외 시 에러 스낵바 노출 및 탭 유지', (WidgetTester tester) async { // given when(mockEventService.fetchEventParticipants(any)).thenThrow(Exception('fetch fail')); await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // when: 참가자 탭 → FAB → 더미 폼 저장(pop(true)) → fetch 예외 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('참가자 추가')); await tester.pumpAndSettle(); expect(find.text('참가자 폼'), findsOneWidget); await tester.tap(find.text('저장')); await tester.pumpAndSettle(); // then: 에러 스낵바 노출, 탭 유지(FAB 존재), fetch 1회 호출 expect(find.byType(SnackBar), findsOneWidget); expect(find.text('참가자 갱신 실패'), findsOneWidget); verify(mockEventService.fetchEventParticipants(any)).called(1); expect(find.byTooltip('참가자 추가'), findsOneWidget); }); testWidgets('점수 탭 실패: 재조회 예외 시 에러 스낵바 노출 및 탭 유지', (WidgetTester tester) async { // given when(mockEventService.fetchEventScores(any)).thenThrow(Exception('fetch fail')); await tester.pumpWidget(createEventDetailsScreen()); await tester.pumpAndSettle(); // when: 점수 탭 → FAB → 더미 폼 저장(pop(true)) → fetch 예외 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('점수 추가')); await tester.pumpAndSettle(); expect(find.text('점수 폼'), findsOneWidget); await tester.tap(find.text('저장')); await tester.pumpAndSettle(); // then: 에러 스낵바 노출, 탭 유지(FAB 존재), fetch 1회 호출 expect(find.byType(SnackBar), findsOneWidget); expect(find.text('점수 갱신 실패'), findsOneWidget); verify(mockEventService.fetchEventScores(any)).called(1); expect(find.byTooltip('점수 추가'), findsOneWidget); }); }); group('로딩 인디케이터 표시/해제 테스트', () { testWidgets('참가자 삭제 시 fetch 대기 중 로딩 오버레이 표시, 완료 후 해제', (WidgetTester tester) async { // given: fetchEventParticipants 지연 설정 + 초기 더미 참가자 주입(삭제 버튼 렌더링 보장) final completer = Completer>(); when(mockEventService.fetchEventParticipants(any)).thenAnswer((_) => completer.future); final dummyParticipants = [ Participant( id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동', ), ]; await tester.pumpWidget(createEventDetailsScreen(participants: dummyParticipants)); await tester.pumpAndSettle(); // when: 참가자 탭 → 삭제 → 확인 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('참가자 삭제').first); await tester.pumpAndSettle(); expect(find.text('삭제 확인'), findsOneWidget); await tester.tap(find.text('삭제')); await tester.pump(); // 비동기 시작 직후 프레임 // then: 로딩 오버레이 표시 expect(find.byKey(const Key('loading_overlay')), findsOneWidget); // when: fetch 완료 completer.complete([]); await tester.pumpAndSettle(); // then: 로딩 오버레이 해제, 스낵바 표시 expect(find.byKey(const Key('loading_overlay')), findsNothing); expect(find.byType(SnackBar), findsOneWidget); }); testWidgets('점수 삭제 시 fetch 대기 중 로딩 오버레이 표시, 완료 후 해제', (WidgetTester tester) async { // given: fetchEventScores 지연 설정 + 초기 더미 점수 주입(삭제 버튼 렌더링 보장) final completer = Completer>(); when(mockEventService.fetchEventScores(any)).thenAnswer((_) => completer.future); final dummyScores = [ Score( id: 's1', memberId: 'm1', eventId: '1', clubId: '1', frames: const [10, 9, 1, 8, 2, 7, 3, 6, 4, 5], totalScore: 150, date: DateTime(2024, 1, 1), participantName: '홍길동', ), ]; // 참가자 이름 매칭을 위해 더미 참가자도 함께 주입 (선택) final dummyParticipants = [ Participant(id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동'), ]; await tester.pumpWidget(createEventDetailsScreen(scores: dummyScores, participants: dummyParticipants)); await tester.pumpAndSettle(); // when: 점수 탭 → 삭제 → 확인 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('점수 삭제').first); await tester.pumpAndSettle(); expect(find.text('삭제 확인'), findsOneWidget); await tester.tap(find.text('삭제')); await tester.pump(); // then: 로딩 오버레이 표시 expect(find.byKey(const Key('loading_overlay')), findsOneWidget); // when: fetch 완료 completer.complete([]); await tester.pumpAndSettle(); // then: 로딩 오버레이 해제, 스낵바 표시 expect(find.byKey(const Key('loading_overlay')), findsNothing); expect(find.byType(SnackBar), findsOneWidget); }); }); group('참가자 드롭다운 로딩/에러 테스트', () { testWidgets('참가자 탭: 필터 드롭다운 변경 시 로딩 오버레이 표시, 완료 후 해제 및 스낵바 표시', (WidgetTester tester) async { // given: fetchEventParticipants 지연 설정 + 초기 더미 참가자 주입(리스트/헤더 렌더링 보장) final completer = Completer>(); when(mockEventService.fetchEventParticipants(any)).thenAnswer((_) => completer.future); final dummyParticipants = [ Participant(id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동'), ]; await tester.pumpWidget(createEventDetailsScreen(participants: dummyParticipants)); await tester.pumpAndSettle(); // when: 참가자 탭으로 이동 → 드롭다운 열기 → '확인됨' 선택 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); final dropdown = find.byKey(const Key('participant_filter_dropdown')); expect(dropdown, findsOneWidget); await tester.tap(dropdown); await tester.pumpAndSettle(); await tester.tap(find.text('확인됨').last); await tester.pump(); // 비동기 시작 직후 프레임 // then: 로딩 오버레이 표시 expect(find.byKey(const Key('loading_overlay')), findsOneWidget); // when: fetch 완료 completer.complete([]); await tester.pumpAndSettle(); // then: 로딩 해제, 스낵바 표시, 서비스 호출 검증 expect(find.byKey(const Key('loading_overlay')), findsNothing); expect(find.byType(SnackBar), findsOneWidget); expect(find.text('참가자가 업데이트되었습니다'), findsOneWidget); verify(mockEventService.fetchEventParticipants(any)).called(1); }); testWidgets('참가자 탭: 필터 드롭다운 변경 실패 시 에러 문구/재시도 버튼 표시, 재시도 성공 시 에러 해제', (WidgetTester tester) async { // given: 최초 드롭다운 변경 시 실패 모킹 when(mockEventService.fetchEventParticipants(any)).thenThrow(Exception('fetch fail')); final dummyParticipants = [ Participant(id: 'p1', eventId: '1', memberId: 'm1', name: '홍길동'), ]; await tester.pumpWidget(createEventDetailsScreen(participants: dummyParticipants)); await tester.pumpAndSettle(); // when: 참가자 탭 이동 → 드롭다운에서 '대기' 선택하여 실패 유도 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); final dropdown = find.byKey(const Key('participant_filter_dropdown')); expect(dropdown, findsOneWidget); await tester.tap(dropdown); await tester.pumpAndSettle(); await tester.tap(find.text('대기').last); await tester.pumpAndSettle(); // then: 에러 UI 노출(문구 + 재시도 버튼) 및 실패 스낵바 expect(find.byKey(const Key('participant_error_text')), findsOneWidget); expect(find.text('참가자 조회 실패'), findsOneWidget); expect(find.byKey(const Key('participant_retry_button')), findsOneWidget); expect(find.text('참가자 갱신 실패'), findsOneWidget); // given: 재시도는 성공으로 모킹 변경 final retryCompleter = Completer>(); when(mockEventService.fetchEventParticipants(any)).thenAnswer((_) => retryCompleter.future); // when: 재시도 버튼 탭 → 로딩 표시 → 완료 await tester.tap(find.byKey(const Key('participant_retry_button'))); await tester.pump(); expect(find.byKey(const Key('loading_overlay')), findsOneWidget); retryCompleter.complete([]); await tester.pumpAndSettle(); // then: 에러 UI/로딩 해제, 서비스 호출 2회 이상 expect(find.byKey(const Key('participant_error_text')), findsNothing); expect(find.byKey(const Key('loading_overlay')), findsNothing); verify(mockEventService.fetchEventParticipants(any)).called(greaterThanOrEqualTo(2)); }); }); group('빈 상태 및 게스트 사전입력 모달 테스트', () { testWidgets('참가자 탭: 참가자가 없을 때 빈 상태 문구가 보여야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen(participants: [])); await tester.pumpAndSettle(); // when: 참가자 탭으로 이동 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); // then: 빈 상태 문구 확인 (TestEventDetailsScreen의 문구와 일치해야 함) expect(find.text('등록된 참가자가 없습니다'), findsOneWidget); }); testWidgets('게스트 사전입력 모달: 저장 실패 버튼 탭 시 실패 스낵바 노출 및 참가자 탭 유지', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen(participants: [])); await tester.pumpAndSettle(); // when: 참가자 탭 → 모달 오픈 → 실패 버튼 탭 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('guest_preinput_button'))); await tester.pumpAndSettle(); expect(find.text('게스트 사전입력'), findsOneWidget); await tester.tap(find.byKey(const Key('guest_preinput_save_fail_button'))); await tester.pumpAndSettle(); // then: 실패 스낵바 노출 및 참가자 탭 빈 상태 유지 expect(find.byType(SnackBar), findsOneWidget); expect(find.text('게스트 기본값 적용에 실패했습니다'), findsOneWidget); expect(find.text('등록된 참가자가 없습니다'), findsOneWidget); }); testWidgets('점수 탭: 점수가 없을 때 빈 상태 문구가 보여야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen(scores: [])); await tester.pumpAndSettle(); // when: 점수 탭으로 이동 await tester.tap(find.text('점수').first); await tester.pumpAndSettle(); // then: 빈 상태 문구 확인 (TestEventDetailsScreen의 문구와 일치해야 함) expect(find.text('등록된 점수가 없습니다'), findsOneWidget); }); testWidgets('게스트 사전입력 모달: 버튼 탭 → 모달 표시 → 저장 시 스낵바 및 탭 유지', (WidgetTester tester) async { // given: 참가자 비어있는 상태에서도 노출 await tester.pumpWidget(createEventDetailsScreen(participants: [])); await tester.pumpAndSettle(); // when: 참가자 탭으로 이동 후, 게스트 사전입력 버튼 탭 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); final guestBtn = find.byKey(const Key('guest_preinput_button')); expect(guestBtn, findsOneWidget); await tester.tap(guestBtn); await tester.pumpAndSettle(); // then: 모달 타이틀 및 기본 라벨 확인 expect(find.text('게스트 사전입력'), findsOneWidget); expect(find.textContaining('상태:'), findsOneWidget); expect(find.textContaining('결제:'), findsOneWidget); // when: 저장 버튼 탭 → 모달 닫힘 및 스낵바 노출 await tester.tap(find.byKey(const Key('guest_preinput_save_button'))); await tester.pumpAndSettle(); // then: 스낵바와 탭 유지(여전히 참가자 탭의 빈 상태 문구 보임) expect(find.byType(SnackBar), findsOneWidget); expect(find.text('게스트 기본값이 적용되었습니다'), findsOneWidget); expect(find.text('등록된 참가자가 없습니다'), findsOneWidget); }); testWidgets('게스트 사전입력 모달: 닫기(취소) 시 스낵바가 표시되지 않아야 함', (WidgetTester tester) async { // given await tester.pumpWidget(createEventDetailsScreen(participants: [])); await tester.pumpAndSettle(); // when: 참가자 탭 → 모달 오픈 → 닫기 버튼 탭 await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('guest_preinput_button'))); await tester.pumpAndSettle(); expect(find.text('게스트 사전입력'), findsOneWidget); await tester.tap(find.byKey(const Key('guest_preinput_close_button'))); await tester.pumpAndSettle(); // then: 스낵바가 없어야 함, 참가자 탭 빈 상태 유지 expect(find.byType(SnackBar), findsNothing); expect(find.text('등록된 참가자가 없습니다'), findsOneWidget); }); testWidgets('참가자 카드: 긴 이름도 RenderFlex overflow 없이 ellipsis 처리', (WidgetTester tester) async { final longName = '아주아주아주아주아주아주아주아주 길고 긴 참가자 이름 테스트 케이스 입니다 1234567890 반복반복반복반복반복'; final participants = [ Participant(id: 'p1', eventId: 'e1', memberId: 'm1', name: longName), ]; await tester.pumpWidget(createEventDetailsScreen(participants: participants)); await tester.pumpAndSettle(); await tester.tap(find.text('참가자').first); await tester.pumpAndSettle(); // 타이틀 Text 위젯이 ellipsis 설정되어 있는지 확인 final nameFinder = find.byKey(const Key('participant_name_0')); expect(nameFinder, findsOneWidget); final nameText = tester.widget(nameFinder); expect(nameText.maxLines, 1); expect(nameText.overflow, TextOverflow.ellipsis); }); }); }