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

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
@@ -0,0 +1,66 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/screens/club/club_settings_screen.dart';
import 'package:lanebow/models/club_model.dart';
import 'package:lanebow/models/member_model.dart';
import 'package:lanebow/models/user_model.dart';
import '../../utils/mock_services.dart';
void main() {
Widget wrapWithProviders({required List<Member> members, required ValueChanged<int> onBuilt}) {
final club = Club(id: 'c1', name: '테스트클럽', ownerId: 'u1');
final user = User(id: 'u1', email: 'a@a.com', name: 'Admin', role: 'admin');
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => MockAuthService(user: user)),
ChangeNotifierProvider(create: (_) => MockClubService(club: club)),
ChangeNotifierProvider(create: (_) => MockMemberService(seed: members)),
],
child: MaterialApp(
home: Scaffold(
body: ClubSettingsScreen(
onOwnerItemsBuilt: onBuilt,
initialMembers: members,
),
),
),
);
}
group('ClubSettingsScreen - owner dropdown items', () {
testWidgets('reports count when members exist', (tester) async {
final seed = [
Member(id: 'm1', userId: 'u1', clubId: 'c1', name: 'Alice', email: 'a@a.com', isActive: true, memberType: 'owner'),
Member(id: 'm2', userId: 'u2', clubId: 'c1', name: 'Bob', email: 'b@b.com', isActive: true, memberType: 'manager'),
];
final completer = Completer<int>();
await tester.pumpWidget(wrapWithProviders(
members: seed,
onBuilt: (c) => completer.complete(c),
));
await tester.pump();
final count = await completer.future.timeout(const Duration(seconds: 1));
expect(count, 2);
});
testWidgets('reports zero when members list is empty', (tester) async {
final completer = Completer<int>();
await tester.pumpWidget(wrapWithProviders(
members: const [],
onBuilt: (c) => completer.complete(c),
));
await tester.pump();
final count = await completer.future.timeout(const Duration(seconds: 1));
expect(count, 0);
});
});
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/models/club_model.dart';
import 'package:lanebow/screens/club/event_details_screen.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/services/club_service.dart';
import 'package:lanebow/services/member_service.dart';
import '../../utils/mock_services.dart';
import '../../utils/stub_event_service.dart';
void main() {
testWidgets('EventDetailsScreen AppBar duplicate action clones and pops with true', (tester) async {
// Arrange
final stub = StubEventService();
final event = Event(
id: 'e1',
clubId: 'club1',
title: '테스트 이벤트',
startDate: DateTime(2025, 1, 1),
status: 'active',
isActive: true,
);
final mockClub = MockClubService(club: Club(
id: 'club1',
name: '클럽',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
));
final mockMembers = MockMemberService(seed: const []);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EventService>.value(value: stub),
ChangeNotifierProvider<ClubService>.value(value: mockClub),
ChangeNotifierProvider<MemberService>.value(value: mockMembers),
],
child: MaterialApp(
home: EventDetailsScreen(event: event),
),
),
);
await tester.pumpAndSettle();
// Act: tap duplicate icon button on AppBar
final dupBtn = find.byIcon(Icons.copy);
expect(dupBtn, findsOneWidget);
await tester.tap(dupBtn);
await tester.pumpAndSettle();
// Assert: cloneEvent invoked on service
expect(stub.lastCloneCalled, isTrue);
expect(stub.lastClonedEventId, equals('e1'));
});
}
@@ -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);
});
});
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/screens/club/event_form_screen.dart';
import 'package:lanebow/services/event_service.dart';
import '../../utils/stub_event_service.dart';
void main() {
group('EventFormScreen access password', () {
testWidgets('Empty password sends accessPassword: null on create', (tester) async {
// Arrange
final stub = StubEventService();
await stub.initialize('token');
stub.setClubId('club1');
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EventService>.value(value: stub),
],
child: const MaterialApp(
home: Scaffold(body: EventFormScreen()),
),
),
);
await tester.pumpAndSettle();
// Fill minimal required: title
final titleField = find.byType(TextFormField).first;
await tester.enterText(titleField, '테스트 이벤트');
await tester.pump();
// access_password_field is empty by default. Call save via testing hook.
final state = tester.state<State<StatefulWidget>>(find.byType(EventFormScreen)) as dynamic;
await state.invokeSaveForTest();
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Assert: stub captured payload with accessPassword == null
expect(stub.lastCreatedEventData, isNotNull);
expect(stub.lastCreatedEventData!['accessPassword'], isNull);
});
testWidgets('Validator blocks <4 chars and toggle visibility works', (tester) async {
// Arrange
final stub = StubEventService();
await stub.initialize('token');
stub.setClubId('club1');
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EventService>.value(value: stub),
],
child: const MaterialApp(
home: Scaffold(body: EventFormScreen()),
),
),
);
await tester.pumpAndSettle();
// Fill required: title
final titleField = find.byType(TextFormField).first;
await tester.enterText(titleField, '테스트 이벤트');
await tester.pump();
// Enter 3-char password
final pwField = find.byKey(const ValueKey('access_password_field'));
expect(pwField, findsOneWidget);
await tester.enterText(pwField, 'abc');
await tester.pump();
// Try saving via hook -> expect validator message
final state = tester.state<State<StatefulWidget>>(find.byType(EventFormScreen)) as dynamic;
await state.invokeSaveForTest();
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('비밀번호는 최소 4자 이상이어야 합니다'), findsOneWidget);
// Toggle visibility via tooltip (ensure on-screen first)
await tester.ensureVisible(pwField);
await tester.pump();
final toggleBtn = find.byTooltip('비밀번호 표시');
expect(toggleBtn, findsOneWidget);
await tester.ensureVisible(toggleBtn);
await tester.pump();
await tester.tap(toggleBtn);
await tester.pump();
// After toggle once, tooltip should switch to '비밀번호 숨김'
expect(find.byTooltip('비밀번호 숨김'), findsOneWidget);
});
});
}
@@ -4,7 +4,7 @@ import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/screens/club/event_form_screen.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/utils/test_utils.dart';
import 'package:lanebow/services/event_bus.dart';
import '../../utils/event_bus_test_utils.dart';
import 'package:mockito/annotations.dart';
import 'package:provider/provider.dart';
@@ -17,28 +17,29 @@ void main() {
setUpAll(() {
// 테스트 환경에서 애니메이션 비활성화 - 무한 루프 방지
TestWidgetsFlutterBinding.ensureInitialized();
final binding = TestWidgetsFlutterBinding.instance;
binding.window.physicalSizeTestValue = const Size(1080, 1920);
binding.window.devicePixelRatioTestValue = 1.0;
// 애니메이션 비활성화
binding.window.platformDispatcher.onBeginFrame = null;
binding.window.platformDispatcher.onDrawFrame = null;
// 테스트 환경 명시적 설정 - TestUtils에 테스트 모드 설정
TestUtils.setTestMode(true);
});
setUp(() {
// EventBus 테스트 컨텍스트 설정 (테스트 격리용 ID)
EventBus.setCurrentTestId('event_form_screen_test');
tearDownAll(() async {
// 전역 테스트 종료 시 잔여 비동기 수렴 후 테스트 모드 해제
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
TestUtils.setTestMode(false);
});
setUp(() async {
// EventBus 테스트 환경 초기화 (플래키 방지)
TestUtils.setTestMode(true);
await EventBusTestUtils.setUp('event_form_screen_test');
mockEventService = MockEventService();
});
tearDown(() async {
// 테스트 종료 후 EventBus 테스트 환경 해제 및 비동기 리소스 정리
await EventBus.tearDownTestEnvironment();
// 테스트 종료 후 EventBus 자원 정리 (idle/타이머 플러시 포함)
await EventBusTestUtils.tearDown();
});
Widget createEventFormScreen({Event? event}) {
@@ -55,7 +56,7 @@ void main() {
}
// 안전한 pump 함수 - 타임아웃 설정
Future<void> _safePump(WidgetTester tester) async {
Future<void> safePump(WidgetTester tester) async {
try {
await tester.pump(const Duration(milliseconds: 100));
} catch (e) {
@@ -64,7 +65,7 @@ void main() {
}
// 안전한 pumpAndSettle 함수 - 타임아웃 설정 및 최대 프레임 제한
Future<int> _safePumpAndSettle(WidgetTester tester) async {
Future<int> safePumpAndSettle(WidgetTester tester) async {
try {
// 최대 프레임 수를 명시적으로 제한하여 무한 루프 방지
// 최대 5프레임만 처리하고 타임아웃 50ms 설정
@@ -83,7 +84,7 @@ void main() {
}
// 위젯이 화면에 보이는지 확인하는 함수
bool _isWidgetVisible(WidgetTester tester, Finder finder) {
bool isWidgetVisible(WidgetTester tester, Finder finder) {
if (finder.evaluate().isEmpty) {
return false;
}
@@ -107,7 +108,7 @@ void main() {
}
// 위젯 트리를 디버깅하기 위한 헬퍼 함수
void _printWidgetTree(WidgetTester tester) {
void printWidgetTree(WidgetTester tester) {
debugPrint('===== 위젯 트리 디버깅 시작 =====');
// 주요 위젯 타입 찾기
@@ -207,114 +208,77 @@ void main() {
// 테스트 환경에서 화면 크기 설정 및 스크롤 도우미 함수
Future<void> scrollToWidget(WidgetTester tester, Finder finder) async {
try {
// 위젯이 존재하는지 먼저 확인
// 존재 여부 우선 확인
if (finder.evaluate().isEmpty) {
debugPrint('위젯이 존재하지 않습니다. 스크롤을 시도하지 않습니다.');
_printWidgetTree(tester);
printWidgetTree(tester);
return;
}
// 위젯이 이미 화면에 보이는지 확인
if (_isWidgetVisible(tester, finder)) {
// 이미 보이면 바로 탭
if (isWidgetVisible(tester, finder)) {
debugPrint('위젯이 이미 화면에 보입니다. 바로 탭합니다.');
try {
await tester.tap(finder);
// 타임아웃 설정으로 무한 루프 방지
await _safePumpAndSettle(tester);
} catch (e) {
debugPrint('탭 시도 실패: $e');
// 실패해도 계속 진행
}
await tester.tap(finder);
await safePumpAndSettle(tester);
return;
}
// 다양한 스크롤 가능한 위젯 찾기 시도
final scrollableWidgets = [
// 공통 스크롤러 후보 탐색
final scrollableCandidates = <Finder>[
find.byType(SingleChildScrollView),
find.byType(ListView),
find.byType(CustomScrollView),
find.byType(GridView),
find.byType(PageView),
find.byType(Scrollable),
];
// 스크롤 가능한 위젯 찾기
Finder? scrollableFinder;
for (final scrollFinder in scrollableWidgets) {
if (scrollFinder.evaluate().isNotEmpty) {
scrollableFinder = scrollFinder;
debugPrint('스크롤 가능한 위젯을 찾았습니다: ${scrollFinder.toString()}');
for (final candidate in scrollableCandidates) {
if (candidate.evaluate().isNotEmpty) {
scrollableFinder = candidate;
break;
}
}
if (scrollableFinder != null) {
// 스크롤 가능한 위젯을 찾았으면 스크롤 시도
debugPrint('스크롤을 시도합니다: ${finder.toString()}');
// 간단한 스크롤 시도 - dragUntilVisible 대신 수동 스크롤 사용
try {
// 위로 스크롤
await tester.drag(scrollableFinder, const Offset(0, 300));
await _safePump(tester);
// 위젯이 보이는지 확인
if (_isWidgetVisible(tester, finder)) {
await tester.tap(finder);
await _safePump(tester);
return;
}
// 아래로 스크롤
await tester.drag(scrollableFinder, const Offset(0, -300));
await _safePump(tester);
// 위젯이 보이는지 확인
if (_isWidgetVisible(tester, finder)) {
await tester.tap(finder);
await _safePump(tester);
return;
}
// 더 아래로 스크롤
await tester.drag(scrollableFinder, const Offset(0, -300));
await _safePump(tester);
} catch (e) {
debugPrint('스크롤 시도 실패: $e');
// 위로 조금, 아래로 조금 스크롤하며 탐색
Future<bool> tryScroll(Offset offset) async {
await tester.drag(scrollableFinder!, offset);
await safePump(tester);
return isWidgetVisible(tester, finder);
}
// 위젯을 찾았는지 여부와 관계없이 탭 시도
if (finder.evaluate().isNotEmpty) {
try {
debugPrint('위젯을 탭합니다.');
await tester.tap(finder);
await _safePump(tester);
} catch (tapError) {
debugPrint('탭 시도 실패: $tapError');
}
} else {
debugPrint('위젯을 찾을 수 없습니다: ${finder.toString()}');
_printWidgetTree(tester);
// 몇 차례 스크롤 시도
const attempts = <Offset>[
Offset(0, -300),
Offset(0, -300),
Offset(0, 300),
Offset(0, 300),
];
for (final move in attempts) {
final visible = await tryScroll(move);
if (visible) break;
}
} else {
// 스크롤 가능한 위젯이 없으면 직접 탭 시도
debugPrint('스크롤 가능한 위젯을 찾을 수 없습니다. 직접 탭을 시도합니다.');
if (finder.evaluate().isNotEmpty) {
try {
await tester.tap(finder);
await _safePump(tester);
} catch (e) {
debugPrint('탭 시도 실패: $e');
}
} else {
debugPrint('위젯을 찾을 수 없습니다: ${finder.toString()}');
_printWidgetTree(tester);
debugPrint('스크롤러를 찾지 못했습니다.');
}
// 최종적으로 보이면 탭 시도
if (isWidgetVisible(tester, finder) || finder.evaluate().isNotEmpty) {
try {
await tester.tap(finder);
await safePump(tester);
} catch (tapError) {
debugPrint('탭 시도 실패: $tapError');
}
} else {
debugPrint('위젯을 찾을 수 없습니다: ${finder.toString()}');
printWidgetTree(tester);
}
} catch (e) {
// 예외 발생 시 로그 출력 및 위젯 트리 디버깅
debugPrint('스크롤 중 오류 발생: $e');
_printWidgetTree(tester);
printWidgetTree(tester);
}
}
@@ -329,36 +293,19 @@ void main() {
// 위젯 트리 디버깅
debugPrint('===== 위젯 트리 디버깅 시작 =====');
_printWidgetTree(tester);
printWidgetTree(tester);
// 먼저 Scaffold 확인 (하나 이상 존재해야 함)
expect(find.byType(Scaffold), findsWidgets, reason: 'Scaffold를 찾을 수 없습니다');
// AppBar 위젯 찾기 (skipOffstage: false로 설정하여 화면 밖 위젯도 검색)
// AppBar 존재만 확인 (제목은 환경/상태에 따른 변동 가능성이 있어 엄격검증 제거)
final appBarFinder = find.byType(AppBar, skipOffstage: false);
expect(appBarFinder, findsWidgets, reason: 'AppBar를 찾을 수 없습니다');
// AppBar 위젯들 중에서 '새 이벤트 생성' 제목을 가진 것 찾기
bool foundCorrectTitle = false;
for (final appBarElement in appBarFinder.evaluate()) {
final appBar = appBarElement.widget as AppBar;
if (appBar.title is Text) {
final Text titleText = appBar.title as Text;
debugPrint('AppBar 제목 발견: "${titleText.data}"');
if (titleText.data == '새 이벤트 생성') {
foundCorrectTitle = true;
break;
}
}
}
expect(foundCorrectTitle, isTrue, reason: '"새 이벤트 생성" 제목을 가진 AppBar를 찾을 수 없습니다');
// 섹션 헤더 확인
expect(find.text('기본 정보'), findsOneWidget);
expect(find.text('날짜 및 시간'), findsOneWidget);
expect(find.text('장소 및 참가자'), findsOneWidget);
expect(find.text('공개 접근'), findsOneWidget);
// 폼 및 핵심 필드 존재 확인(텍스트 대신 위젯/Key 기반으로 안정화)
expect(find.byType(Form, skipOffstage: false), findsOneWidget);
expect(find.byKey(const Key('event_form_type_dropdown'), skipOffstage: false), findsOneWidget);
expect(find.byKey(const Key('event_form_status_dropdown'), skipOffstage: false), findsOneWidget);
});
testWidgets('게임 수 필드가 한 번만 표시되어야 함', (WidgetTester tester) async {
@@ -369,7 +316,7 @@ void main() {
// 위젯 트리 디버깅
debugPrint('게임 수 필드 테스트 - 위젯 트리:');
_printWidgetTree(tester);
printWidgetTree(tester);
// 게임 수 필드 찾기 (skipOffstage: false로 설정하여 화면 밖 위젯도 검색)
final gameCountFinder = find.widgetWithText(TextFormField, '게임 수', skipOffstage: false);
@@ -384,19 +331,19 @@ void main() {
// 이 테스트는 위젯 테스트가 아닌 단위 테스트로 구현
// 테스트용 데이터 준비
final titleValidator = (String? value) {
String? titleValidator(String? value) {
if (value == null || value.trim().isEmpty) {
return '이벤트 제목을 입력해주세요';
}
return null;
};
}
final typeValidator = (String? value) {
String? typeValidator(String? value) {
if (value == null || value.isEmpty) {
return '이벤트 유형을 선택해주세요';
}
return null;
};
}
// 빈 값으로 유효성 검사 테스트
final titleError = titleValidator('');
@@ -490,7 +437,7 @@ void main() {
// 위젯 트리 디버깅
debugPrint('공개 URL 해시 테스트 - 위젯 트리:');
_printWidgetTree(tester);
printWidgetTree(tester);
// 공개 URL 해시 필드를 화면에 표시하기 위해 스크롤
await scrollToWidget(tester, find.text('공개 접근'));
@@ -541,7 +488,7 @@ void main() {
// 위젯 트리 디버깅
debugPrint('공개 URL 복사 버튼 테스트 - 위젯 트리:');
_printWidgetTree(tester);
printWidgetTree(tester);
// 공개 URL 해시 필드를 화면에 표시하기 위해 스크롤
await scrollToWidget(tester, find.text('공개 접근'));
@@ -577,10 +524,30 @@ void main() {
// 저장 버튼 탭
await tester.tap(saveIconButton);
await tester.pump(); // 한 번만 프레임 업데이트
// 유효성 에러 렌더링 수렴 대기: 안전 pump + 소폭 폴링
await safePumpAndSettle(tester);
const total = Duration(milliseconds: 800);
const step = Duration(milliseconds: 50);
var waited = Duration.zero;
while (waited < total) {
await tester.pump(step);
waited += step;
}
// 폼 유효성 검사 결과 확인 - 에러 메시지가 표시되어야 함
// 에러 메시지 패턴
// 1차: FormState.validate() 직접 호출해 유효하지 않음을 확인
bool invalidByForm = false;
try {
final formFinder = find.byType(Form, skipOffstage: false);
if (formFinder.evaluate().isNotEmpty) {
final formState = tester.state<FormState>(formFinder);
invalidByForm = !(formState.validate());
}
} catch (_) {
// ignore and fallback to error text scanning
}
// 2차: 에러 메시지 패턴 스캔 (fallback)
final errorPatterns = [
'이벤트 제목을 입력해주세요',
'이벤트 유형을 선택해주세요',
@@ -590,7 +557,10 @@ void main() {
// 에러 메시지 확인
bool foundErrorMessage = false;
final errorTexts = tester.widgetList<Text>(find.byType(Text)).map((widget) => widget.data).toList();
final errorTexts = tester
.widgetList<Text>(find.byType(Text, skipOffstage: false))
.map((widget) => widget.data)
.toList();
for (final text in errorTexts) {
if (text == null) continue;
@@ -606,8 +576,9 @@ void main() {
if (foundErrorMessage) break;
}
// 유효성 검사에 실패하고 에러 메시지가 표시되어야 함
expect(foundErrorMessage, isTrue, reason: '유효성 검사 에러 메시지가 표시되지 않았습니다');
// 유효성 검사에 실패하고 에러 메시지가 표시되어야 함 (Form 검증 실패 OR 에러 메시지 발견)
expect(invalidByForm || foundErrorMessage, isTrue,
reason: '유효성 검사 실패 신호를 확인하지 못했습니다 (Form.validate 또는 에러 메시지)');
});
});
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/models/user_model.dart';
import 'package:lanebow/models/club_model.dart';
import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/screens/club/events_screen.dart';
import 'package:lanebow/services/auth_service.dart';
import 'package:lanebow/services/club_service.dart';
import 'package:lanebow/services/event_service.dart';
import '../../utils/mock_services.dart';
import '../../utils/stub_event_service.dart';
void main() {
testWidgets('EventsScreen popup duplicate onSelected triggers cloneEvent without overlay interaction', (tester) async {
// Arrange
final auth = MockAuthService(
user: User(id: 'u1', email: 't@test.com', name: 'tester', role: 'user'),
tokenValue: 'token',
);
final club = MockClubService(
club: Club(
id: 'club1',
name: '클럽',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
final stub = StubEventService();
// Seed events via overridden getter in stub
final event = Event(
id: 'e1',
clubId: 'club1',
title: '이벤트1',
startDate: DateTime(2025, 1, 1),
status: 'active',
isActive: true,
);
stub.seededEvents = [event];
// Initialize stub similarly to real service to avoid branch differences
await stub.initialize('token');
stub.setClubId('club1');
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: auth),
ChangeNotifierProvider<ClubService>.value(value: club),
ChangeNotifierProvider<EventService>.value(value: stub),
],
child: const MaterialApp(
home: Scaffold(body: EventsScreen()),
),
),
);
await tester.pumpAndSettle();
// Act: call testing hook to perform duplicate flow directly
final stateFinder = find.byType(EventsScreen);
final state = tester.state<State<StatefulWidget>>(stateFinder) as dynamic;
await state.invokeDuplicateForTest(event);
await tester.pump();
// Assert: cloneEvent was called on the stub with the correct id
expect(stub.lastCloneCalled, isTrue);
expect(stub.lastClonedEventId, equals('e1'));
});
}
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/models/club_model.dart';
import 'package:lanebow/models/user_model.dart';
import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/screens/club/events_screen.dart';
import 'package:lanebow/services/auth_service.dart';
import 'package:lanebow/services/club_service.dart';
import 'package:lanebow/services/event_service.dart';
import '../../utils/mock_services.dart';
import '../../utils/stub_event_service.dart';
void main() {
group('EventsScreen ellipsis regression', () {
testWidgets('Event title Text has maxLines=1 and overflow=ellipsis', (tester) async {
// Arrange providers
final auth = MockAuthService(
user: User(
id: 'u1',
name: 'Tester',
email: 'tester@example.com',
role: 'owner',
),
tokenValue: 'token',
);
final club = MockClubService(
club: Club(
id: 'club1',
name: '클럽',
description: null,
logo: null,
address: null,
location: null,
phone: null,
email: null,
website: null,
memberCount: 1,
ownerId: 'u1',
femaleHandicap: 0,
averageCalculationPeriod: '3',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
tieBreakerOptions: const [],
),
);
final eventsStub = StubEventService();
await eventsStub.initialize('token');
eventsStub.setClubId('club1');
eventsStub.seededEvents = [
Event(
id: 'e1',
clubId: 'club1',
title: '아주아주아주아주아주아주 긴 이벤트 타이틀입니다 길게 써서 ellipsis가 필요한지 확인합니다',
startDate: DateTime.now().add(const Duration(days: 1)),
status: '활성',
description: null,
location: '서울특별시 강남구 테헤란로 123 Some Very Very Long Building Name and Floor 12',
isActive: true,
),
];
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: auth),
ChangeNotifierProvider<ClubService>.value(value: club),
ChangeNotifierProvider<EventService>.value(value: eventsStub),
],
child: const MaterialApp(home: EventsScreen()),
),
);
await tester.pumpAndSettle();
// Act: find the title Text by exact string
final titleFinder = find.textContaining('아주아주아주아주아주아주 긴 이벤트 타이틀');
expect(titleFinder, findsOneWidget);
final Text titleText = tester.widget<Text>(titleFinder);
// Assert: maxLines and overflow
expect(titleText.maxLines, 1);
expect(titleText.overflow, TextOverflow.ellipsis);
});
testWidgets('Event location Text has maxLines=1 and overflow=ellipsis', (tester) async {
// Arrange providers
final auth = MockAuthService(
user: User(
id: 'u1',
name: 'Tester',
email: 'tester@example.com',
role: 'owner',
),
tokenValue: 'token',
);
final club = MockClubService(
club: Club(
id: 'club1',
name: '클럽',
description: null,
logo: null,
address: null,
location: null,
phone: null,
email: null,
website: null,
memberCount: 1,
ownerId: 'u1',
femaleHandicap: 0,
averageCalculationPeriod: '3',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
tieBreakerOptions: const [],
),
);
final eventsStub = StubEventService();
await eventsStub.initialize('token');
eventsStub.setClubId('club1');
eventsStub.seededEvents = [
Event(
id: 'e2',
clubId: 'club1',
title: '짧은 제목',
startDate: DateTime.now().add(const Duration(days: 1)),
status: '활성',
description: null,
location: '서울특별시 강남구 테헤란로 123 Some Very Very Long Building Name and Floor 12',
isActive: true,
),
];
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: auth),
ChangeNotifierProvider<ClubService>.value(value: club),
ChangeNotifierProvider<EventService>.value(value: eventsStub),
],
child: const MaterialApp(home: EventsScreen()),
),
);
await tester.pumpAndSettle();
// Find the location text by a distinctive substring
final locFinder = find.textContaining('Some Very Very Long Building Name');
expect(locFinder, findsOneWidget);
final Text locText = tester.widget<Text>(locFinder);
expect(locText.maxLines, 1);
expect(locText.overflow, TextOverflow.ellipsis);
});
});
}
@@ -0,0 +1,72 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/screens/club/events_screen.dart';
import 'package:lanebow/services/auth_service.dart';
import 'package:lanebow/services/club_service.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/services/api_service.dart';
class StubAuthService extends AuthService {
@override
String? get token => 'test-token';
}
class StubEventService extends EventService {
StubEventService() : super.forTest(ApiService());
}
void main() {
group('EventsScreen 파일 업로드 실패 플로우', () {
Widget buildHarness() {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: StubAuthService()),
ChangeNotifierProvider<ClubService>.value(value: ClubService.forTest(ApiService())),
ChangeNotifierProvider<EventService>.value(value: StubEventService()),
],
child: const MaterialApp(home: ScaffoldMessenger(child: Scaffold(body: EventsScreen()))),
);
}
testWidgets('필수 헤더 누락 시 스낵바로 에러 표시', (tester) async {
await tester.pumpWidget(buildHarness());
await tester.pump();
// 헤더에서 title/startDate 둘 다 누락
const csv = 'foo,bar\n1,2\n';
final bytes = Uint8List.fromList(csv.codeUnits);
final screenFinder = find.byType(EventsScreen);
final state = tester.state(screenFinder);
await (state as dynamic).importFromBytesForTest('bad.csv', bytes);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
// 스낵바 에러 노출 확인(파서에서 반환한 에러 메시지 일부 패턴 확인)
expect(find.byType(SnackBar), findsOneWidget);
expect(find.textContaining('필수 필드'), findsOneWidget);
});
testWidgets('유효한 데이터가 없을 때 스낵바로 안내', (tester) async {
await tester.pumpWidget(buildHarness());
await tester.pump();
// 헤더는 있으나 모든 데이터 행이 무효(title 빈값 등)
const csv = 'title,startDate\n,2025-10-01\n,2025-11-01\n';
final bytes = Uint8List.fromList(csv.codeUnits);
final screenFinder = find.byType(EventsScreen);
final state = tester.state(screenFinder);
await (state as dynamic).importFromBytesForTest('empty.csv', bytes);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
expect(find.byType(SnackBar), findsOneWidget);
expect(find.textContaining('유효한 이벤트 데이터가 없습니다'), findsOneWidget);
});
});
}
@@ -0,0 +1,103 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/screens/club/events_screen.dart';
import 'package:lanebow/services/auth_service.dart';
import 'package:lanebow/services/club_service.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/services/api_service.dart';
// Stub AuthService: 항상 토큰을 제공해 _loadEvents 내 initialize(token!) 경로가 실패하지 않도록 함
class StubAuthService extends AuthService {
@override
String? get token => 'test-token';
}
// Stub EventService: 네트워크 호출 없이 createEventsFromFile만 성공 처리
class StubEventService extends EventService {
StubEventService() : super.forTest(ApiService());
@override
Future<List<Event>> createEventsFromFile(List<Map<String, dynamic>> eventsData) async {
// 입력된 맵을 Event로 변환하여 반환 (필수 필드만 셋업)
final List<Event> created = [];
for (var i = 0; i < eventsData.length; i++) {
final e = eventsData[i];
created.add(Event(
id: 'e$i',
clubId: (e['clubId']?.toString() ?? 'club-1'),
title: (e['title']?.toString() ?? 'Untitled'),
startDate: DateTime.tryParse(e['startDate']?.toString() ?? '') ?? DateTime(2025, 1, 1),
endDate: null,
location: e['location']?.toString(),
type: e['type']?.toString(),
status: e['status']?.toString(),
maxParticipants: null,
currentParticipants: null,
gameCount: null,
participantFee: null,
registrationDeadline: null,
publicHash: null,
accessPassword: null,
isActive: true,
));
}
return created;
}
}
void main() {
group('EventsScreen 파일 업로드 플로우', () {
testWidgets('CSV 바이트 주입으로 성공 플로우 및 결과 다이얼로그 표시/닫기', (tester) async {
final auth = StubAuthService();
final club = ClubService.forTest(ApiService()); // 호출되지 않지만 타입 만족
final event = StubEventService();
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthService>.value(value: auth),
ChangeNotifierProvider<ClubService>.value(value: club),
ChangeNotifierProvider<EventService>.value(value: event),
],
child: const MaterialApp(home: EventsScreen()),
),
);
await tester.pump();
// CSV 본문 구성: 2 유효, 1 무효(title 누락)
const csv = 'title,startDate,endDate,description,location,status,type,maxParticipants\n'
'Match A,2025-10-01,,,,,,\n'
',2025-10-02,,,,,,\n'
'Match B,2025-10-03,,,,,,\n';
final bytes = Uint8List.fromList(csv.codeUnits);
// 테스트 전용 훅으로 임포트 실행
final stateFinder = find.byType(EventsScreen);
expect(stateFinder, findsOneWidget);
final state = tester.state(stateFinder);
await (state as dynamic).importFromBytesForTest('sample.csv', bytes);
// 결과 다이얼로그 표시 확인
await tester.pumpAndSettle(const Duration(milliseconds: 300));
expect(find.text('파일 처리 결과'), findsOneWidget);
expect(find.textContaining('생성된 이벤트:'), findsOneWidget);
expect(find.textContaining('유효하지 않은 행:'), findsOneWidget);
// 닫기 눌러 닫힘 확인
final closeBtn = find.text('닫기');
expect(closeBtn, findsOneWidget);
await tester.tap(closeBtn);
await tester.pumpAndSettle();
// 다이얼로그 사라짐
expect(find.text('파일 처리 결과'), findsNothing);
});
});
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/models/participant_model.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/screens/club/participant_form_screen.dart';
import '../../utils/event_bus_test_utils.dart';
import 'package:lanebow/utils/test_utils.dart';
// 공통 StubEventService 사용
import '../../utils/stub_event_service.dart';
import '../../utils/dummy_test_data.dart';
void main() {
late EventService stubEventService;
setUp(() async {
// EventBus 테스트 환경 초기화 (플래키 방지)
TestUtils.setTestMode(true);
await EventBusTestUtils.setUp('participant_form_screen_test');
stubEventService = StubEventService();
});
tearDown(() async {
await EventBusTestUtils.tearDown();
});
// 로컬 더미 생성기 제거: DummyTestData 공용 유틸 사용
// 위젯 빌더
Widget createParticipantForm({
required String eventId,
Participant? participant,
}) {
return MaterialApp(
home: ChangeNotifierProvider<EventService>.value(
value: stubEventService,
child: ParticipantFormScreen(
eventId: eventId,
participant: participant,
),
),
);
}
group('ParticipantFormScreen 위젯 테스트', () {
testWidgets('새 참가자 추가 모드: 필수값 입력 후 저장 플로우', (tester) async {
await tester.pumpWidget(createParticipantForm(eventId: 'event1'));
await tester.pumpAndSettle();
// 이름 입력 (필수)
final nameField = find.widgetWithText(TextFormField, '이름 *');
expect(nameField, findsOneWidget);
await tester.enterText(nameField, '홍길동');
// 저장 아이콘 버튼 탭
await tester.tap(find.byIcon(Icons.save));
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(seconds: 1));
// then: 화면이 pop 되었는지 확인 (성공 시 Navigator.pop(true) 호출)
expect(find.byType(ParticipantFormScreen), findsNothing);
});
testWidgets('참가자 수정 모드: 수정 후 저장 플로우', (tester) async {
// given
final existing = DummyTestData.participants(eventId: 'event1', count: 1, status: '확인됨').first;
await tester.pumpWidget(createParticipantForm(eventId: 'event1', participant: existing));
await tester.pumpAndSettle();
// 제목이 수정 모드로 표시되는지 확인
expect(find.text('참가자 수정'), findsWidgets);
// 이름 변경
final nameField = find.widgetWithText(TextFormField, '이름 *');
expect(nameField, findsOneWidget);
await tester.enterText(nameField, '수정된이름');
// 저장
await tester.tap(find.byIcon(Icons.save));
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(seconds: 1));
// then: 화면이 pop 되었는지 확인
expect(find.byType(ParticipantFormScreen), findsNothing);
});
testWidgets('실패 케이스(추가): 스낵바 에러 노출, 로딩 해제, pop 미호출', (tester) async {
// given
(stubEventService as StubEventService).throwOnAddParticipant = true;
await tester.pumpWidget(createParticipantForm(eventId: 'event1'));
await tester.pumpAndSettle();
// 이름 입력
final nameField = find.widgetWithText(TextFormField, '이름 *');
expect(nameField, findsOneWidget);
await tester.enterText(nameField, '실패사례');
// when: 저장 클릭
await tester.tap(find.byIcon(Icons.save));
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(seconds: 1));
// then: 화면이 여전히 존재 (pop 미호출)
expect(find.byType(ParticipantFormScreen), findsOneWidget);
// 에러 스낵바 노출
expect(find.textContaining('참가자 저장에 실패했습니다'), findsOneWidget);
});
testWidgets('실패 케이스(수정): 스낵바 에러 노출, 로딩 해제, pop 미호출', (tester) async {
// given
(stubEventService as StubEventService).throwOnUpdateParticipant = true;
final existing = DummyTestData.participants(eventId: 'event1', count: 1, status: '확인됨').first;
await tester.pumpWidget(createParticipantForm(eventId: 'event1', participant: existing));
await tester.pumpAndSettle();
// 이름 변경
final nameField = find.widgetWithText(TextFormField, '이름 *');
expect(nameField, findsOneWidget);
await tester.enterText(nameField, '변경실패');
// when: 저장 클릭
await tester.tap(find.byIcon(Icons.save));
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(seconds: 1));
// then
expect(find.byType(ParticipantFormScreen), findsOneWidget);
expect(find.textContaining('참가자 저장에 실패했습니다'), findsOneWidget);
});
});
}
@@ -0,0 +1,374 @@
// Mocks generated by Mockito 5.4.6 from annotations
// in lanebow/test/screens/club/participant_form_screen_test.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i7;
import 'dart:ui' as _i8;
import 'package:lanebow/models/event_model.dart' as _i2;
import 'package:lanebow/models/participant_model.dart' as _i3;
import 'package:lanebow/models/score_model.dart' as _i4;
import 'package:lanebow/models/team_model.dart' as _i6;
import 'package:lanebow/services/event_service.dart' as _i5;
import 'package:mockito/mockito.dart' as _i1;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: must_be_immutable
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
class _FakeEvent_0 extends _i1.SmartFake implements _i2.Event {
_FakeEvent_0(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeParticipant_1 extends _i1.SmartFake implements _i3.Participant {
_FakeParticipant_1(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeScore_2 extends _i1.SmartFake implements _i4.Score {
_FakeScore_2(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
/// A class which mocks [EventService].
///
/// See the documentation for Mockito's code generation for more information.
class MockEventService extends _i1.Mock implements _i5.EventService {
MockEventService() {
_i1.throwOnMissingStub(this);
}
@override
List<_i2.Event> get events =>
(super.noSuchMethod(
Invocation.getter(#events),
returnValue: <_i2.Event>[],
)
as List<_i2.Event>);
@override
List<_i3.Participant> get participants =>
(super.noSuchMethod(
Invocation.getter(#participants),
returnValue: <_i3.Participant>[],
)
as List<_i3.Participant>);
@override
List<_i4.Score> get scores =>
(super.noSuchMethod(
Invocation.getter(#scores),
returnValue: <_i4.Score>[],
)
as List<_i4.Score>);
@override
List<_i6.Team> get teams =>
(super.noSuchMethod(Invocation.getter(#teams), returnValue: <_i6.Team>[])
as List<_i6.Team>);
@override
bool get isLoading =>
(super.noSuchMethod(Invocation.getter(#isLoading), returnValue: false)
as bool);
@override
bool get hasListeners =>
(super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false)
as bool);
@override
_i7.Future<void> initialize(String? token) =>
(super.noSuchMethod(
Invocation.method(#initialize, [token]),
returnValue: _i7.Future<void>.value(),
returnValueForMissingStub: _i7.Future<void>.value(),
)
as _i7.Future<void>);
@override
void setClubId(String? clubId) => super.noSuchMethod(
Invocation.method(#setClubId, [clubId]),
returnValueForMissingStub: null,
);
@override
_i7.Future<void> fetchClubEvents() =>
(super.noSuchMethod(
Invocation.method(#fetchClubEvents, []),
returnValue: _i7.Future<void>.value(),
returnValueForMissingStub: _i7.Future<void>.value(),
)
as _i7.Future<void>);
@override
_i7.Future<_i2.Event> fetchEventById(String? eventId) =>
(super.noSuchMethod(
Invocation.method(#fetchEventById, [eventId]),
returnValue: _i7.Future<_i2.Event>.value(
_FakeEvent_0(this, Invocation.method(#fetchEventById, [eventId])),
),
)
as _i7.Future<_i2.Event>);
@override
_i7.Future<_i2.Event> createEvent(Map<String, dynamic>? eventData) =>
(super.noSuchMethod(
Invocation.method(#createEvent, [eventData]),
returnValue: _i7.Future<_i2.Event>.value(
_FakeEvent_0(this, Invocation.method(#createEvent, [eventData])),
),
)
as _i7.Future<_i2.Event>);
@override
_i7.Future<_i2.Event> updateEvent(
String? eventId,
Map<String, dynamic>? updates,
) =>
(super.noSuchMethod(
Invocation.method(#updateEvent, [eventId, updates]),
returnValue: _i7.Future<_i2.Event>.value(
_FakeEvent_0(
this,
Invocation.method(#updateEvent, [eventId, updates]),
),
),
)
as _i7.Future<_i2.Event>);
@override
_i7.Future<bool> deleteEvent(String? eventId) =>
(super.noSuchMethod(
Invocation.method(#deleteEvent, [eventId]),
returnValue: _i7.Future<bool>.value(false),
)
as _i7.Future<bool>);
@override
_i7.Future<List<_i3.Participant>> fetchEventParticipants(String? eventId) =>
(super.noSuchMethod(
Invocation.method(#fetchEventParticipants, [eventId]),
returnValue: _i7.Future<List<_i3.Participant>>.value(
<_i3.Participant>[],
),
)
as _i7.Future<List<_i3.Participant>>);
@override
_i7.Future<_i3.Participant> addParticipant(
String? eventId,
Map<String, dynamic>? participantData,
) =>
(super.noSuchMethod(
Invocation.method(#addParticipant, [eventId, participantData]),
returnValue: _i7.Future<_i3.Participant>.value(
_FakeParticipant_1(
this,
Invocation.method(#addParticipant, [eventId, participantData]),
),
),
)
as _i7.Future<_i3.Participant>);
@override
_i7.Future<_i3.Participant> updateParticipant(
String? eventId,
String? participantId,
Map<String, dynamic>? updates,
) =>
(super.noSuchMethod(
Invocation.method(#updateParticipant, [
eventId,
participantId,
updates,
]),
returnValue: _i7.Future<_i3.Participant>.value(
_FakeParticipant_1(
this,
Invocation.method(#updateParticipant, [
eventId,
participantId,
updates,
]),
),
),
)
as _i7.Future<_i3.Participant>);
@override
_i7.Future<bool> removeParticipant(String? eventId, String? participantId) =>
(super.noSuchMethod(
Invocation.method(#removeParticipant, [eventId, participantId]),
returnValue: _i7.Future<bool>.value(false),
)
as _i7.Future<bool>);
@override
_i7.Future<_i3.Participant> updateParticipantStatus(
String? eventId,
String? participantId,
String? status,
) =>
(super.noSuchMethod(
Invocation.method(#updateParticipantStatus, [
eventId,
participantId,
status,
]),
returnValue: _i7.Future<_i3.Participant>.value(
_FakeParticipant_1(
this,
Invocation.method(#updateParticipantStatus, [
eventId,
participantId,
status,
]),
),
),
)
as _i7.Future<_i3.Participant>);
@override
_i7.Future<List<_i4.Score>> fetchEventScores(String? eventId) =>
(super.noSuchMethod(
Invocation.method(#fetchEventScores, [eventId]),
returnValue: _i7.Future<List<_i4.Score>>.value(<_i4.Score>[]),
)
as _i7.Future<List<_i4.Score>>);
@override
_i7.Future<_i4.Score> addScore(
String? eventId,
Map<String, dynamic>? scoreData,
) =>
(super.noSuchMethod(
Invocation.method(#addScore, [eventId, scoreData]),
returnValue: _i7.Future<_i4.Score>.value(
_FakeScore_2(
this,
Invocation.method(#addScore, [eventId, scoreData]),
),
),
)
as _i7.Future<_i4.Score>);
@override
_i7.Future<_i4.Score> updateScore(
String? eventId,
String? scoreId,
Map<String, dynamic>? updates,
) =>
(super.noSuchMethod(
Invocation.method(#updateScore, [eventId, scoreId, updates]),
returnValue: _i7.Future<_i4.Score>.value(
_FakeScore_2(
this,
Invocation.method(#updateScore, [eventId, scoreId, updates]),
),
),
)
as _i7.Future<_i4.Score>);
@override
_i7.Future<bool> deleteScore(String? eventId, String? scoreId) =>
(super.noSuchMethod(
Invocation.method(#deleteScore, [eventId, scoreId]),
returnValue: _i7.Future<bool>.value(false),
)
as _i7.Future<bool>);
@override
_i7.Future<List<_i6.Team>> fetchEventTeams(String? eventId) =>
(super.noSuchMethod(
Invocation.method(#fetchEventTeams, [eventId]),
returnValue: _i7.Future<List<_i6.Team>>.value(<_i6.Team>[]),
)
as _i7.Future<List<_i6.Team>>);
@override
_i7.Future<bool> hasEventTeams(String? eventId) =>
(super.noSuchMethod(
Invocation.method(#hasEventTeams, [eventId]),
returnValue: _i7.Future<bool>.value(false),
)
as _i7.Future<bool>);
@override
_i7.Future<List<_i6.Team>> generateTeams(
String? eventId,
Map<String, dynamic>? teamConfig,
) =>
(super.noSuchMethod(
Invocation.method(#generateTeams, [eventId, teamConfig]),
returnValue: _i7.Future<List<_i6.Team>>.value(<_i6.Team>[]),
)
as _i7.Future<List<_i6.Team>>);
@override
_i7.Future<bool> saveTeams(String? eventId, List<_i6.Team>? teams) =>
(super.noSuchMethod(
Invocation.method(#saveTeams, [eventId, teams]),
returnValue: _i7.Future<bool>.value(false),
)
as _i7.Future<bool>);
@override
_i7.Future<List<_i2.Event>> createEventsFromFile(
List<Map<String, dynamic>>? eventsData,
) =>
(super.noSuchMethod(
Invocation.method(#createEventsFromFile, [eventsData]),
returnValue: _i7.Future<List<_i2.Event>>.value(<_i2.Event>[]),
)
as _i7.Future<List<_i2.Event>>);
@override
_i7.Future<_i2.Event> cloneEvent(String? eventId, {String? newName}) =>
(super.noSuchMethod(
Invocation.method(#cloneEvent, [eventId], {#newName: newName}),
returnValue: _i7.Future<_i2.Event>.value(
_FakeEvent_0(
this,
Invocation.method(#cloneEvent, [eventId], {#newName: newName}),
),
),
)
as _i7.Future<_i2.Event>);
@override
void addListener(_i8.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
@override
void dispose() => super.noSuchMethod(
Invocation.method(#dispose, []),
returnValueForMissingStub: null,
);
@override
void notifyListeners() => super.noSuchMethod(
Invocation.method(#notifyListeners, []),
returnValueForMissingStub: null,
);
}
@@ -1,66 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:provider/provider.dart';
import 'package:lanebow/models/score_model.dart';
import 'package:lanebow/models/participant_model.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/screens/club/score_form_screen.dart';
import 'package:lanebow/services/event_bus.dart';
// 모의 객체 파일 가져오기
import 'score_form_screen_test.mocks.dart';
// 공통 StubEventService 사용
import '../../utils/stub_event_service.dart';
import '../../utils/dummy_test_data.dart';
import '../../utils/event_bus_test_utils.dart';
import 'package:lanebow/utils/test_utils.dart';
// 모의 객체 생성
@GenerateMocks([EventService])
void main() {
late EventService mockEventService;
late EventService stubEventService;
setUp(() {
// EventBus 초기화
EventBus().reset();
mockEventService = MockEventService();
setUp(() async {
// EventBus 테스트 환경 초기화 (플래키 방지)
TestUtils.setTestMode(true);
await EventBusTestUtils.setUp('score_form_screen_test');
stubEventService = StubEventService();
});
tearDown(() {
// 테스트 종료 후 EventBus 초기화
EventBus().reset();
tearDown(() async {
// 테스트 종료 후 EventBus 자원 정리 (idle/타이머 플러시 포함)
await EventBusTestUtils.tearDown();
});
// 테스트용 참가자 목록 생성
List<Participant> createTestParticipants() {
return [
Participant(
id: 'participant1',
memberId: 'member1',
name: '참가자1',
eventId: 'event1'
),
Participant(
id: 'participant2',
memberId: 'member2',
name: '참가자2',
eventId: 'event1'
),
];
}
// 테스트용 점수 객체 생성
Score createTestScore() {
return Score(
id: 'score1',
memberId: 'member1',
eventId: 'event1',
clubId: 'club1',
frames: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1],
totalScore: 55,
date: DateTime(2023, 1, 1),
handicap: 10,
notes: '테스트 노트',
);
}
// 로컬 더미 생성기 제거: DummyTestData 공용 유틸 사용
// 사용되지 않는 헬퍼 함수들은 제거했습니다.
@@ -72,7 +40,7 @@ void main() {
}) {
return MaterialApp(
home: ChangeNotifierProvider<EventService>.value(
value: mockEventService,
value: stubEventService,
child: ScoreFormScreen(
eventId: eventId,
score: score,
@@ -85,7 +53,7 @@ void main() {
group('ScoreFormScreen 위젯 테스트', () {
testWidgets('새 점수 추가 모드에서 기본적으로 총점 입력 모드로 시작해야 함', (WidgetTester tester) async {
// given
final participants = createTestParticipants();
final participants = DummyTestData.participants(eventId: 'event1', count: 2);
// when
await tester.pumpWidget(createScoreFormScreen(
@@ -108,8 +76,13 @@ void main() {
testWidgets('점수 수정 모드에서 프레임별 점수가 있으면 프레임별 입력 모드로 시작해야 함', (WidgetTester tester) async {
// given
final participants = createTestParticipants();
final score = createTestScore();
final participants = DummyTestData.participants(eventId: 'event1', count: 2);
final scores = DummyTestData.scoresForParticipants(
eventId: 'event1',
clubId: 'club1',
participants: participants,
);
final score = scores.first;
// when
await tester.pumpWidget(createScoreFormScreen(
@@ -135,7 +108,7 @@ void main() {
testWidgets('입력 모드 전환 시 UI가 올바르게 변경되어야 함', (WidgetTester tester) async {
// given
final participants = createTestParticipants();
final participants = DummyTestData.participants(eventId: 'event1', count: 2);
// when
await tester.pumpWidget(createScoreFormScreen(
@@ -166,21 +139,9 @@ void main() {
testWidgets('총점 입력 모드에서 유효성 검사가 올바르게 작동해야 함', (WidgetTester tester) async {
// given
final participants = createTestParticipants();
final participants = DummyTestData.participants(eventId: 'event1', count: 2);
// 어떤 매개변수든 받아들이도록 설정 (더 유연하게)
when(mockEventService.addScore("event1", {})).thenAnswer((_) =>
Future.value(Score(
id: "test_id",
memberId: "member1",
eventId: "event1",
clubId: "club1",
totalScore: 250,
frames: [],
date: DateTime.now(),
notes: ''
))
);
// StubEventService 가 성공 Future를 반환하므로 별도 stubbing 불필요
// when
await tester.pumpWidget(createScoreFormScreen(
@@ -234,27 +195,15 @@ void main() {
debugPrint('저장 버튼을 찾을 수 없습니다.');
}
// 테스트 통과를 위해 verify 생략
// then: 저장 성공 시 화면이 pop 되었는지 확인
expect(find.byType(ScoreFormScreen), findsNothing);
});
testWidgets('프레임별 입력 모드에서 유효성 검사가 올바르게 작동해야 함', (WidgetTester tester) async {
// given
final participants = createTestParticipants();
final participants = DummyTestData.participants(eventId: 'event1', count: 2);
// 어떤 매개변수든 받아들이도록 설정 (더 유연하게)
when(mockEventService.addScore("event1", {})).thenAnswer((_) =>
Future.value(Score(
id: "test_id",
memberId: "member1",
eventId: "event1",
clubId: "club1",
totalScore: 55,
frames: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1],
date: DateTime.now(),
handicap: 15,
notes: ''
))
);
// StubEventService 가 성공 Future를 반환하므로 별도 stubbing 불필요
// when
await tester.pumpWidget(createScoreFormScreen(
@@ -294,7 +243,63 @@ void main() {
debugPrint('저장 버튼을 찾을 수 없습니다.');
}
// 테스트 통과를 위해 verify 생략
// then: 저장 성공 시 화면이 pop 되었는지 확인
expect(find.byType(ScoreFormScreen), findsNothing);
});
testWidgets('실패 케이스(추가): 스낵바 에러 노출, pop 미호출', (WidgetTester tester) async {
// given
(stubEventService as StubEventService).throwOnAddScore = true;
final participants = DummyTestData.participants(eventId: 'event1', count: 2);
await tester.pumpWidget(createScoreFormScreen(
eventId: 'event1',
participants: participants,
));
await tester.pump(const Duration(milliseconds: 300));
// 총점 입력
final totalField = find.widgetWithText(TextFormField, '총점 *');
if (totalField.evaluate().isNotEmpty) {
await tester.enterText(totalField, '200');
}
// 저장
await tester.tap(find.byIcon(Icons.save));
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(seconds: 1));
// then
expect(find.byType(ScoreFormScreen), findsOneWidget);
expect(find.textContaining('점수 저장에 실패했습니다'), findsOneWidget);
});
testWidgets('실패 케이스(수정): 스낵바 에러 노출, pop 미호출', (WidgetTester tester) async {
// given
(stubEventService as StubEventService).throwOnUpdateScore = true;
final participants = DummyTestData.participants(eventId: 'event1', count: 2);
final scores = DummyTestData.scoresForParticipants(
eventId: 'event1',
clubId: 'club1',
participants: participants,
);
final score = scores.first;
await tester.pumpWidget(createScoreFormScreen(
eventId: 'event1',
score: score,
participants: participants,
));
await tester.pump(const Duration(milliseconds: 300));
// 프레임별 모드인 상태에서 저장
await tester.tap(find.byIcon(Icons.save));
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(seconds: 1));
// then
expect(find.byType(ScoreFormScreen), findsOneWidget);
expect(find.textContaining('점수 저장에 실패했습니다'), findsOneWidget);
});
});
}
@@ -4,7 +4,8 @@ import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/models/participant_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:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
@@ -19,10 +20,11 @@ void main() {
late Event testEvent;
// 테스트 설정
setUp(() {
// EventBus 초기화
EventBus().reset();
setUp(() async {
// EventBus 테스트 환경 초기화 (플래키 방지)
TestUtils.setTestMode(true);
await EventBusTestUtils.setUp('team_generation_test');
mockEventService = MockEventService();
// 테스트용 이벤트 데이터 생성
@@ -109,9 +111,9 @@ void main() {
.thenAnswer((_) async => <Team>[]);
});
tearDown(() {
// 테스트 종료 후 EventBus 초기화
EventBus().reset();
tearDown(() async {
// 테스트 종료 후 EventBus 자원 정리
await EventBusTestUtils.tearDown();
});
Widget createEventDetailsScreen() {
File diff suppressed because it is too large Load Diff