Files
bowlingManager/mobile/test/screens/club/event_details_screen_test.dart
T

962 lines
39 KiB
Dart

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 => <Participant>[]);
when(mockEventService.fetchEventScores(any))
.thenAnswer((_) async => <Score>[]);
// 팀 데이터 모킹 - 팀 탭이 표시되도록 비어있지 않은 팀 목록 반환
when(mockEventService.fetchEventTeams(any))
.thenAnswer((_) async => <Team>[Team(id: '1', eventId: '1', name: '팀 1', memberIds: [])]);
});
tearDown(() async {
// 테스트 종료 후 EventBus 자원 정리 (idle/타이머 플러시 포함)
await EventBusTestUtils.tearDown();
});
// 스크롤 도우미 함수
Future<void> scrollToWidget(WidgetTester tester, Finder finder) async {
await tester.dragUntilVisible(
finder,
find.byType(SingleChildScrollView),
const Offset(0, -100),
);
await tester.pumpAndSettle();
}
Widget createEventDetailsScreen({List<Score>? scores, List<Participant>? participants}) {
// 테스트용 위젯 사용
return MaterialApp(
home: ChangeNotifierProvider<EventService>.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>[
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<List<Participant>>();
when(mockEventService.fetchEventParticipants(any)).thenAnswer((_) => retryCompleter.future);
// when: 재시도 수행
await tester.tap(find.byKey(const Key('participant_retry_button')));
await tester.pump();
retryCompleter.complete(<Participant>[]);
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<Row>());
// 공유 옵션 확인 (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<List<Score>>();
when(mockEventService.fetchEventScores(any)).thenAnswer((_) => completer.future);
final dummyScores = <Score>[
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>[
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(<Score>[]);
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>[
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>[
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<List<Score>>();
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(<Score>[]);
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<List<Participant>>();
when(mockEventService.fetchEventParticipants(any)).thenAnswer((_) => completer.future);
final dummyParticipants = <Participant>[
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(<Participant>[]);
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<List<Score>>();
when(mockEventService.fetchEventScores(any)).thenAnswer((_) => completer.future);
final dummyScores = <Score>[
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>[
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(<Score>[]);
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<List<Participant>>();
when(mockEventService.fetchEventParticipants(any)).thenAnswer((_) => completer.future);
final dummyParticipants = <Participant>[
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(<Participant>[]);
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>[
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<List<Participant>>();
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(<Participant>[]);
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<Text>(nameFinder);
expect(nameText.maxLines, 1);
expect(nameText.overflow, TextOverflow.ellipsis);
});
});
}