이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입

This commit is contained in:
2025-09-12 06:33:44 +09:00
parent 107abc963f
commit a5f2544d11
121 changed files with 39277 additions and 3911 deletions
@@ -1,4 +1,5 @@
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';
@@ -6,11 +7,14 @@ 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 'package:lanebow/services/event_bus.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])
@@ -23,14 +27,24 @@ void main() {
// 클립보드 및 플러그인 모킹 설정
TestWidgetsFlutterBinding.ensureInitialized();
// 클립보드 모킹
const MethodChannel('plugins.flutter.io/clipboard')
.setMockMethodCallHandler((MethodCall methodCall) async {
// 클립보드 모킹 (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) {
@@ -43,29 +57,18 @@ void main() {
FlutterError.presentError(details);
};
setUp(() {
// EventBus 초기화
EventBus().reset();
setUp(() async {
// EventBus 테스트 환경 초기화 (플래키 방지)
TestUtils.setTestMode(true);
await EventBusTestUtils.setUp('event_details_screen_test');
mockEventService = MockEventService();
// 테스트용 이벤트 데이터 생성
testEvent = Event(
// 테스트용 이벤트 데이터 생성 (공용 더미 유틸 활용)
testEvent = DummyTestData.event(
id: '1',
clubId: '1',
title: '테스트 이벤트',
description: '테스트 설명',
type: '팀전', // 팀전으로 변경하여 팀 탭이 표시되도록 설정
status: '예정',
startDate: DateTime.now(),
endDate: DateTime.now().add(const Duration(hours: 3)),
location: '테스트 볼링장',
maxParticipants: 20,
gameCount: 3,
participantFee: 10000,
registrationDeadline: DateTime.now().add(const Duration(days: 1)),
publicHash: 'test123',
accessPassword: 'pass123',
isActive: true,
);
@@ -79,9 +82,9 @@ void main() {
.thenAnswer((_) async => <Team>[Team(id: '1', eventId: '1', name: '팀 1', memberIds: [])]);
});
tearDown(() {
// 테스트 종료 후 EventBus 초기화
EventBus().reset();
tearDown(() async {
// 테스트 종료 후 EventBus 자원 정리 (idle/타이머 플러시 포함)
await EventBusTestUtils.tearDown();
});
// 스크롤 도우미 함수
@@ -124,6 +127,47 @@ void main() {
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());
@@ -217,106 +261,208 @@ void main() {
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));
});
});
// 테스트용 점수 데이터 생성 함수
List<Score> createTestScores() {
return [
// 1위: 100점
Score(
id: '1',
memberId: '1',
eventId: '1',
clubId: '1',
frames: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
totalScore: 100,
handicap: 0,
date: DateTime.now(),
notes: '',
participantName: '참가자 1',
),
// 공동 2위: 90점, 핸디캡 10
Score(
id: '2',
memberId: '2',
eventId: '1',
clubId: '1',
frames: [9, 9, 9, 9, 9, 9, 9, 9, 9, 9],
totalScore: 90,
handicap: 10, // 핸디캡 포함 시 100점
date: DateTime.now(),
notes: '',
participantName: '참가자 2',
),
// 공동 2위: 90점, 핸디캡 0
Score(
id: '3',
memberId: '3',
eventId: '1',
clubId: '1',
frames: [9, 9, 9, 9, 9, 9, 9, 9, 9, 9],
totalScore: 90,
handicap: 0,
date: DateTime.now(),
notes: '',
participantName: '참가자 3',
),
// 4위: 80점, 핸디캡 20
Score(
id: '4',
memberId: '4',
eventId: '1',
clubId: '1',
frames: [8, 8, 8, 8, 8, 8, 8, 8, 8, 8],
totalScore: 80,
handicap: 20, // 핸디캡 포함 시 100점
date: DateTime.now(),
notes: '',
participantName: '참가자 4',
),
];
}
group('팀 탭 내비게이션 및 저장 플로우 테스트', () {
testWidgets('팀 탭에서 재생성 버튼 탭 시 TeamGeneratorScreen으로 이동해야 함', (WidgetTester tester) async {
// given
await tester.pumpWidget(createEventDetailsScreen());
await tester.pumpAndSettle();
// 테스트용 참가자 데이터 생성 함수
List<Participant> createTestParticipants() {
return [
Participant(
id: '1',
eventId: '1',
memberId: '1',
name: '참가자 1',
status: 'confirmed',
),
Participant(
id: '2',
eventId: '1',
memberId: '2',
name: '참가자 2',
status: 'confirmed',
),
Participant(
id: '3',
eventId: '1',
memberId: '3',
name: '참가자 3',
status: 'confirmed',
),
Participant(
id: '4',
eventId: '1',
memberId: '4',
name: '참가자 4',
status: 'confirmed',
),
];
}
// 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 testScores = createTestScores();
final testParticipants = createTestParticipants();
// 동점자가 있는 테스트 점수/참가자 데이터 설정 (공용 더미 유틸)
final testParticipants = DummyTestData.participants(eventId: '1', count: 4);
final testScores = DummyTestData.scoresForParticipants(
eventId: '1',
clubId: '1',
participants: testParticipants,
);
// when
await tester.pumpWidget(createEventDetailsScreen(
@@ -339,9 +485,13 @@ void main() {
testWidgets('핸디캡 포함 전환 시 순위가 올바르게 업데이트되어야 함', (WidgetTester tester) async {
// given
// 핸디캡이 다른 테스트 점수 데이터 설정
final testScores = createTestScores();
final testParticipants = createTestParticipants();
// 핸디캡이 다른 테스트 점수 데이터 설정 (공용 더미 유틸)
final testParticipants = DummyTestData.participants(eventId: '1', count: 4);
final testScores = DummyTestData.scoresForParticipants(
eventId: '1',
clubId: '1',
participants: testParticipants,
);
// when
await tester.pumpWidget(createEventDetailsScreen(
@@ -421,4 +571,391 @@ void main() {
// 스트라이크와 스페어 표시는 위젯 트리에서 찾기 어려울 수 있으므로 생략
});
});
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);
});
});
}